Initial commit: TZZR Orchestrator v5
- Framework genérico multi-agente
- Providers: Claude CLI, LiteLLM (100+ modelos)
- Tools: bash, read, write, glob, grep, ssh, http
- Seguridad: sandbox paths, validación comandos, rate limiting
- Configuración via YAML + .env
🤖 Generated with Claude Code
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# .gitignore
|
||||
|
||||
# Credenciales - NUNCA subir
|
||||
.env
|
||||
*.env
|
||||
.env.*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Logs y outputs
|
||||
logs/*.md
|
||||
!logs/.gitkeep
|
||||
outputs/*
|
||||
!outputs/.gitkeep
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
200
README.md
Normal file
200
README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# LLM Orchestrator
|
||||
|
||||
Sistema de orquestación multi-agente compatible con cualquier LLM.
|
||||
|
||||
## ¿Qué es esto?
|
||||
|
||||
Un framework para crear y coordinar múltiples agentes de IA que pueden:
|
||||
- Ejecutar comandos en tu sistema
|
||||
- Leer y escribir archivos
|
||||
- Conectarse a servidores via SSH
|
||||
- Hacer llamadas a APIs
|
||||
- Trabajar juntos en tareas complejas
|
||||
|
||||
## Características
|
||||
|
||||
- **Multi-modelo**: Claude, GPT-4, Gemini, Llama, Mistral, y 100+ más
|
||||
- **Herramientas universales**: Bash, lectura/escritura de archivos, SSH, HTTP
|
||||
- **Agentes personalizables**: Define tantos agentes como necesites
|
||||
- **LOGs automáticos**: Registro de todas las acciones
|
||||
- **Sin dependencias pesadas**: Solo Python estándar + LiteLLM opcional
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
# Clonar o descomprimir
|
||||
cd orchestrator
|
||||
|
||||
# Crear entorno virtual
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Instalar dependencias opcionales
|
||||
pip install litellm # Para usar GPT-4, Gemini, Llama, etc.
|
||||
```
|
||||
|
||||
## Uso rápido
|
||||
|
||||
### 1. Define tus agentes
|
||||
|
||||
Edita `config.yaml`:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
researcher:
|
||||
role: "Investigador que busca información"
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools: [bash, read, http_request]
|
||||
|
||||
coder:
|
||||
role: "Programador que escribe código"
|
||||
provider: litellm
|
||||
model: gpt4o
|
||||
tools: [read, write, bash]
|
||||
|
||||
reviewer:
|
||||
role: "Revisor que valida el trabajo"
|
||||
provider: litellm
|
||||
model: gemini-pro
|
||||
tools: [read, grep]
|
||||
```
|
||||
|
||||
### 2. Ejecuta el orquestador
|
||||
|
||||
```bash
|
||||
# Modo interactivo
|
||||
python orchestrator/main.py
|
||||
|
||||
# Ejecutar un agente específico
|
||||
python orchestrator/main.py --agent researcher --prompt "Busca información sobre X"
|
||||
|
||||
# Ver estado
|
||||
python orchestrator/main.py --status
|
||||
```
|
||||
|
||||
### 3. Comandos interactivos
|
||||
|
||||
```
|
||||
/status - Ver estado del sistema
|
||||
/agents - Listar agentes disponibles
|
||||
/agent <nombre> - Cambiar agente activo
|
||||
/logs <agente> - Ver historial del agente
|
||||
/all - Ejecutar en todos los agentes
|
||||
/quit - Salir
|
||||
```
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
orchestrator/
|
||||
├── config.yaml # ← Tu configuración de agentes
|
||||
├── orchestrator/
|
||||
│ ├── main.py # Punto de entrada
|
||||
│ ├── config.py # Carga de configuración
|
||||
│ ├── providers/ # Conexión con LLMs
|
||||
│ │ ├── claude_provider.py
|
||||
│ │ └── litellm_provider.py
|
||||
│ ├── tools/ # Herramientas disponibles
|
||||
│ │ ├── executor.py
|
||||
│ │ └── definitions.py
|
||||
│ ├── agents/ # Lógica de agentes
|
||||
│ │ └── base.py
|
||||
│ └── tasks/ # Tareas predefinidas
|
||||
├── logs/ # Historial por agente
|
||||
├── outputs/ # Archivos generados
|
||||
└── examples/ # Ejemplos de configuración
|
||||
```
|
||||
|
||||
## Providers disponibles
|
||||
|
||||
| Provider | Modelos | Requisito |
|
||||
|----------|---------|-----------|
|
||||
| `claude` | sonnet, opus, haiku | Claude Code CLI instalado |
|
||||
| `litellm` | gpt4o, gemini-pro, llama3, mistral... | `pip install litellm` + API keys |
|
||||
|
||||
## Herramientas disponibles
|
||||
|
||||
| Herramienta | Descripción |
|
||||
|-------------|-------------|
|
||||
| `bash` | Ejecuta comandos del sistema |
|
||||
| `read` | Lee archivos |
|
||||
| `write` | Escribe/crea archivos |
|
||||
| `glob` | Busca archivos por patrón |
|
||||
| `grep` | Busca texto en archivos |
|
||||
| `ssh` | Ejecuta comandos en servidores remotos |
|
||||
| `http_request` | Hace peticiones HTTP/API |
|
||||
| `list_dir` | Lista contenido de directorios |
|
||||
|
||||
## Ejemplos
|
||||
|
||||
### Agente simple (solo conversación)
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
assistant:
|
||||
role: "Asistente general"
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools: [] # Sin herramientas
|
||||
```
|
||||
|
||||
### Equipo de desarrollo
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
architect:
|
||||
role: "Diseña la arquitectura del sistema"
|
||||
provider: claude
|
||||
model: opus
|
||||
tools: [read, write, bash]
|
||||
|
||||
developer:
|
||||
role: "Implementa el código"
|
||||
provider: litellm
|
||||
model: gpt4o
|
||||
tools: [read, write, bash, grep]
|
||||
|
||||
tester:
|
||||
role: "Escribe y ejecuta tests"
|
||||
provider: litellm
|
||||
model: gemini-pro
|
||||
tools: [read, bash]
|
||||
```
|
||||
|
||||
### Agentes con servidores
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
production:
|
||||
host: 192.168.1.100
|
||||
user: deploy
|
||||
key: ~/.ssh/id_rsa
|
||||
|
||||
staging:
|
||||
host: 192.168.1.101
|
||||
user: deploy
|
||||
key: ~/.ssh/id_rsa
|
||||
|
||||
agents:
|
||||
deployer:
|
||||
role: "Despliega aplicaciones a servidores"
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools: [ssh, bash, read]
|
||||
servers: [production, staging]
|
||||
```
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
Para usar modelos de pago via LiteLLM:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export GOOGLE_API_KEY="..."
|
||||
export ANTHROPIC_API_KEY="..." # Si usas Claude via API
|
||||
```
|
||||
|
||||
## Licencia
|
||||
|
||||
MIT - Usa, modifica y comparte libremente.
|
||||
122
config.yaml
Normal file
122
config.yaml
Normal file
@@ -0,0 +1,122 @@
|
||||
# config.yaml - Configuración del orquestador
|
||||
#
|
||||
# Edita este archivo para definir tus agentes y servidores.
|
||||
# Puedes tener tantos agentes como necesites.
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN GENERAL
|
||||
# ============================================================================
|
||||
|
||||
settings:
|
||||
# Modelo por defecto si no se especifica en el agente
|
||||
default_provider: claude
|
||||
default_model: sonnet
|
||||
|
||||
# Timeout en segundos para las llamadas
|
||||
timeout: 300
|
||||
|
||||
# Directorio de trabajo (relativo a este archivo)
|
||||
working_dir: .
|
||||
|
||||
# Máximo de iteraciones de herramientas por turno
|
||||
max_tool_iterations: 10
|
||||
|
||||
# ============================================================================
|
||||
# SERVIDORES (opcional)
|
||||
# ============================================================================
|
||||
# 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"
|
||||
|
||||
# ============================================================================
|
||||
# AGENTES
|
||||
# ============================================================================
|
||||
# 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:
|
||||
role: |
|
||||
Eres un asistente general que ayuda con tareas diversas.
|
||||
Puedes ejecutar comandos, leer y escribir archivos.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- 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
|
||||
|
||||
# ============================================================================
|
||||
# TAREAS PREDEFINIDAS (opcional)
|
||||
# ============================================================================
|
||||
# Define secuencias de acciones que se ejecutan automáticamente
|
||||
|
||||
tasks:
|
||||
# Ejemplo:
|
||||
# deploy:
|
||||
# description: "Despliega la aplicación a producción"
|
||||
# steps:
|
||||
# - agent: coder
|
||||
# prompt: "Ejecuta los tests"
|
||||
# - agent: deployer
|
||||
# prompt: "Despliega a producción"
|
||||
|
||||
# ============================================================================
|
||||
# NOTAS
|
||||
# ============================================================================
|
||||
#
|
||||
# PROVIDERS DISPONIBLES:
|
||||
# - claude: Usa Claude Code CLI (requiere suscripción o API key)
|
||||
# - litellm: Usa LiteLLM para acceder a 100+ modelos
|
||||
#
|
||||
# MODELOS LITELLM (ejemplos):
|
||||
# - gpt4o, gpt4-turbo, o1 (OpenAI)
|
||||
# - gemini-pro, gemini-flash (Google)
|
||||
# - mistral, mixtral (Mistral)
|
||||
# - llama3, codellama (Ollama local)
|
||||
# - groq-llama (Groq - muy rápido)
|
||||
#
|
||||
# HERRAMIENTAS:
|
||||
# - bash: Ejecuta comandos del sistema
|
||||
# - read: Lee archivos
|
||||
# - write: Escribe/crea archivos
|
||||
# - glob: Busca archivos por patrón (*.py, **/*.md)
|
||||
# - grep: Busca texto en archivos
|
||||
# - ssh: Ejecuta comandos en servidores remotos
|
||||
# - http_request: Hace peticiones HTTP
|
||||
# - list_dir: Lista directorios
|
||||
63
examples/dev_team.yaml
Normal file
63
examples/dev_team.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
# examples/dev_team.yaml
|
||||
# Ejemplo: Equipo de desarrollo de software
|
||||
|
||||
settings:
|
||||
default_provider: claude
|
||||
default_model: sonnet
|
||||
timeout: 300
|
||||
|
||||
agents:
|
||||
architect:
|
||||
role: |
|
||||
Eres un arquitecto de software senior.
|
||||
Diseñas sistemas escalables y mantenibles.
|
||||
Tomas decisiones técnicas importantes.
|
||||
Documentas tus decisiones en ADRs (Architecture Decision Records).
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- list_dir
|
||||
- glob
|
||||
|
||||
developer:
|
||||
role: |
|
||||
Eres un desarrollador full-stack experimentado.
|
||||
Escribes código limpio, bien documentado y testeable.
|
||||
Sigues las mejores prácticas del lenguaje que uses.
|
||||
Siempre incluyes manejo de errores apropiado.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- bash
|
||||
- grep
|
||||
- glob
|
||||
|
||||
reviewer:
|
||||
role: |
|
||||
Eres un revisor de código exigente pero constructivo.
|
||||
Buscas bugs, problemas de seguridad y mejoras.
|
||||
Sugieres refactorizaciones cuando son necesarias.
|
||||
Validas que el código siga los estándares.
|
||||
provider: litellm
|
||||
model: gpt4o
|
||||
tools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
|
||||
tester:
|
||||
role: |
|
||||
Eres un ingeniero de QA especializado en testing.
|
||||
Escribes tests unitarios, de integración y e2e.
|
||||
Identificas edge cases y escenarios de error.
|
||||
Aseguras buena cobertura de tests.
|
||||
provider: litellm
|
||||
model: gemini-pro
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- bash
|
||||
77
examples/devops.yaml
Normal file
77
examples/devops.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
# examples/devops.yaml
|
||||
# Ejemplo: Equipo DevOps con servidores
|
||||
|
||||
settings:
|
||||
default_provider: claude
|
||||
default_model: sonnet
|
||||
timeout: 300
|
||||
|
||||
servers:
|
||||
production:
|
||||
host: prod.example.com
|
||||
user: deploy
|
||||
key: ~/.ssh/prod_key
|
||||
description: "Servidor de producción"
|
||||
|
||||
staging:
|
||||
host: staging.example.com
|
||||
user: deploy
|
||||
key: ~/.ssh/staging_key
|
||||
description: "Servidor de staging"
|
||||
|
||||
monitoring:
|
||||
host: monitor.example.com
|
||||
user: admin
|
||||
key: ~/.ssh/monitor_key
|
||||
description: "Servidor de monitoreo"
|
||||
|
||||
agents:
|
||||
deployer:
|
||||
role: |
|
||||
Eres un ingeniero de deploy experimentado.
|
||||
Despliegas aplicaciones de forma segura.
|
||||
Siempre haces backup antes de cambios.
|
||||
Verificas el estado después de cada deploy.
|
||||
NUNCA ejecutas comandos destructivos sin confirmación.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools:
|
||||
- ssh
|
||||
- bash
|
||||
- read
|
||||
servers:
|
||||
- production
|
||||
- staging
|
||||
|
||||
monitor:
|
||||
role: |
|
||||
Eres un especialista en monitoreo.
|
||||
Verificas métricas y logs.
|
||||
Identificas anomalías y problemas.
|
||||
Alertas sobre situaciones críticas.
|
||||
provider: claude
|
||||
model: haiku
|
||||
tools:
|
||||
- ssh
|
||||
- bash
|
||||
- http_request
|
||||
servers:
|
||||
- monitoring
|
||||
- production
|
||||
|
||||
security:
|
||||
role: |
|
||||
Eres un ingeniero de seguridad.
|
||||
Auditas configuraciones y permisos.
|
||||
Buscas vulnerabilidades.
|
||||
Recomiendas mejoras de seguridad.
|
||||
provider: litellm
|
||||
model: gpt4o
|
||||
tools:
|
||||
- ssh
|
||||
- read
|
||||
- bash
|
||||
- grep
|
||||
servers:
|
||||
- production
|
||||
- staging
|
||||
46
examples/local_ollama.yaml
Normal file
46
examples/local_ollama.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# examples/local_ollama.yaml
|
||||
# Ejemplo: Usando modelos locales con Ollama
|
||||
#
|
||||
# Requisitos:
|
||||
# 1. Instalar Ollama: https://ollama.ai
|
||||
# 2. Descargar modelos: ollama pull llama3
|
||||
# 3. Ollama debe estar corriendo: ollama serve
|
||||
|
||||
settings:
|
||||
default_provider: litellm
|
||||
default_model: llama3
|
||||
timeout: 600 # Modelos locales pueden ser más lentos
|
||||
|
||||
agents:
|
||||
coder:
|
||||
role: |
|
||||
Eres un programador que ayuda con código.
|
||||
Explicas tu razonamiento paso a paso.
|
||||
provider: litellm
|
||||
model: codellama
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- bash
|
||||
|
||||
writer:
|
||||
role: |
|
||||
Eres un escritor creativo.
|
||||
Ayudas con textos, emails y documentos.
|
||||
provider: litellm
|
||||
model: llama3
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
|
||||
analyst:
|
||||
role: |
|
||||
Eres un analista de datos.
|
||||
Procesas archivos y extraes información.
|
||||
provider: litellm
|
||||
model: mixtral-local
|
||||
tools:
|
||||
- read
|
||||
- bash
|
||||
- glob
|
||||
- grep
|
||||
59
examples/research.yaml
Normal file
59
examples/research.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
# examples/research.yaml
|
||||
# Ejemplo: Equipo de investigación
|
||||
|
||||
settings:
|
||||
default_provider: litellm
|
||||
default_model: gpt4o
|
||||
timeout: 600 # Más tiempo para investigación
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
role: |
|
||||
Eres un investigador académico metódico.
|
||||
Buscas información de fuentes confiables.
|
||||
Citas tus fuentes apropiadamente.
|
||||
Identificas gaps en el conocimiento actual.
|
||||
provider: litellm
|
||||
model: gpt4o
|
||||
tools:
|
||||
- http_request
|
||||
- read
|
||||
- write
|
||||
|
||||
analyst:
|
||||
role: |
|
||||
Eres un analista de datos experto.
|
||||
Procesas y analizas grandes cantidades de información.
|
||||
Encuentras patrones y tendencias.
|
||||
Presentas datos de forma clara y visual.
|
||||
provider: litellm
|
||||
model: gemini-pro
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
- bash
|
||||
- glob
|
||||
|
||||
writer:
|
||||
role: |
|
||||
Eres un escritor técnico profesional.
|
||||
Conviertes información compleja en texto claro.
|
||||
Adaptas el tono al público objetivo.
|
||||
Estructuras documentos de forma lógica.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
|
||||
editor:
|
||||
role: |
|
||||
Eres un editor riguroso.
|
||||
Corriges gramática, estilo y claridad.
|
||||
Verificas consistencia en todo el documento.
|
||||
Mejoras la legibilidad sin cambiar el mensaje.
|
||||
provider: claude
|
||||
model: haiku # Rápido para edición
|
||||
tools:
|
||||
- read
|
||||
- write
|
||||
21
examples/simple.yaml
Normal file
21
examples/simple.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
# examples/simple.yaml
|
||||
# Ejemplo: Un solo agente asistente
|
||||
|
||||
settings:
|
||||
default_provider: claude
|
||||
default_model: sonnet
|
||||
timeout: 300
|
||||
|
||||
agents:
|
||||
assistant:
|
||||
role: |
|
||||
Eres un asistente útil y amable.
|
||||
Ayudas con cualquier tarea que te pidan.
|
||||
Eres claro y conciso en tus respuestas.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- list_dir
|
||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
8
orchestrator/__init__.py
Normal file
8
orchestrator/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# orchestrator/__init__.py
|
||||
"""LLM Orchestrator - Sistema de orquestación multi-agente seguro."""
|
||||
|
||||
from .config import get_config, reload_config
|
||||
from .agents import Agent, AgentResult
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__all__ = ["get_config", "reload_config", "Agent", "AgentResult"]
|
||||
6
orchestrator/agents/__init__.py
Normal file
6
orchestrator/agents/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# orchestrator/agents/__init__.py
|
||||
"""Agentes del orquestador."""
|
||||
|
||||
from .base import Agent, AgentResult
|
||||
|
||||
__all__ = ["Agent", "AgentResult"]
|
||||
182
orchestrator/agents/base.py
Normal file
182
orchestrator/agents/base.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# orchestrator/agents/base.py
|
||||
"""Clase base para agentes."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from config import get_config, AgentConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
"""Resultado de una ejecución de agente."""
|
||||
success: bool
|
||||
output: str
|
||||
agent_name: str
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
error: Optional[str] = None
|
||||
tool_results: list = field(default_factory=list)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class Agent:
|
||||
"""
|
||||
Agente de IA configurable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "",
|
||||
role: str = "",
|
||||
provider: str = "claude",
|
||||
model: str = "sonnet",
|
||||
tools: list = None,
|
||||
servers: list = None,
|
||||
config_obj: AgentConfig = None
|
||||
):
|
||||
if config_obj:
|
||||
self.name = config_obj.name
|
||||
self.role = config_obj.role
|
||||
self.provider_name = config_obj.provider
|
||||
self.model = config_obj.model
|
||||
self.tools = config_obj.tools or []
|
||||
self.servers = config_obj.servers or []
|
||||
else:
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.provider_name = provider
|
||||
self.model = model
|
||||
self.tools = tools or []
|
||||
self.servers = servers or []
|
||||
|
||||
self.config = get_config()
|
||||
|
||||
self.log_file = self.config.logs_dir / f"{self.name}.md"
|
||||
self.output_dir = self.config.outputs_dir / self.name
|
||||
|
||||
self.provider = self._create_provider()
|
||||
self.system_prompt = self._build_system_prompt()
|
||||
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.log_file.exists():
|
||||
self._init_log()
|
||||
|
||||
def _create_provider(self):
|
||||
if self.provider_name == "claude":
|
||||
from providers import ClaudeProvider
|
||||
return ClaudeProvider(
|
||||
model=self.model,
|
||||
timeout=self.config.settings.timeout,
|
||||
rate_limit_per_minute=self.config.settings.rate_limit_per_minute,
|
||||
max_retries=self.config.settings.max_retries,
|
||||
)
|
||||
else:
|
||||
from providers import LiteLLMProvider
|
||||
return LiteLLMProvider(
|
||||
model=self.model if self.provider_name == "litellm" else self.provider_name,
|
||||
timeout=self.config.settings.timeout,
|
||||
working_dir=str(self.config.base_dir),
|
||||
max_tool_iterations=self.config.settings.max_tool_iterations,
|
||||
rate_limit_per_minute=self.config.settings.rate_limit_per_minute,
|
||||
max_retries=self.config.settings.max_retries,
|
||||
)
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
prompt = f"# {self.name}\n\n{self.role}"
|
||||
|
||||
if self.servers:
|
||||
prompt += "\n\n## Servidores\n"
|
||||
for server_name in self.servers:
|
||||
server = self.config.get_server(server_name)
|
||||
if server:
|
||||
prompt += f"- {server_name}: {server.user}@{server.host}\n"
|
||||
|
||||
return prompt
|
||||
|
||||
def _init_log(self):
|
||||
header = f"""# {self.name}
|
||||
|
||||
- **Creado:** {datetime.now().isoformat()}
|
||||
- **Provider:** {self.provider_name}
|
||||
- **Modelo:** {self.model}
|
||||
- **Herramientas:** {', '.join(self.tools) if self.tools else 'ninguna'}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
self.log_file.write_text(header)
|
||||
|
||||
def log(self, entry_type: str, content: str):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
entry = f"\n## {timestamp} | {entry_type}\n{content}\n"
|
||||
|
||||
with open(self.log_file, "a") as f:
|
||||
f.write(entry)
|
||||
|
||||
async def run(self, prompt: str, log_action: bool = True) -> AgentResult:
|
||||
if log_action:
|
||||
self.log("PROMPT", prompt[:200] + "..." if len(prompt) > 200 else prompt)
|
||||
|
||||
try:
|
||||
response = await self.provider.run_with_tools(
|
||||
prompt=prompt,
|
||||
tools=self.tools,
|
||||
system_prompt=self.system_prompt,
|
||||
)
|
||||
|
||||
result = AgentResult(
|
||||
success=response.success,
|
||||
output=response.text,
|
||||
agent_name=self.name,
|
||||
error=response.error,
|
||||
tool_results=response.tool_results if hasattr(response, 'tool_results') else [],
|
||||
metadata={
|
||||
"usage": response.usage,
|
||||
"retries": response.retries,
|
||||
} if response.usage else {}
|
||||
)
|
||||
|
||||
if log_action:
|
||||
status = "RESULTADO" if response.success else "ERROR"
|
||||
content = response.text[:500] if response.success else (response.error or "Error desconocido")
|
||||
self.log(status, content)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if log_action:
|
||||
self.log("ERROR", str(e))
|
||||
|
||||
return AgentResult(
|
||||
success=False, output="",
|
||||
agent_name=self.name, error=str(e)
|
||||
)
|
||||
|
||||
def save_output(self, filename: str, content: str) -> Path:
|
||||
filepath = self.output_dir / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
filepath.write_text(content)
|
||||
self.log("ARCHIVO", f"Creado: {filepath}")
|
||||
return filepath
|
||||
|
||||
def read_log(self, last_n: int = None) -> str:
|
||||
if not self.log_file.exists():
|
||||
return ""
|
||||
|
||||
content = self.log_file.read_text()
|
||||
|
||||
if last_n:
|
||||
entries = content.split("\n## ")
|
||||
return "\n## ".join(entries[-last_n:])
|
||||
|
||||
return content
|
||||
|
||||
def __repr__(self):
|
||||
return f"Agent({self.name}, {self.provider_name}/{self.model})"
|
||||
284
orchestrator/config.py
Normal file
284
orchestrator/config.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# orchestrator/config.py
|
||||
"""
|
||||
Configuración segura del orquestador.
|
||||
|
||||
Carga configuración desde:
|
||||
1. Variables de entorno
|
||||
2. Archivo .env
|
||||
3. config.yaml
|
||||
|
||||
NUNCA hardcodea credenciales aquí.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
def load_env():
|
||||
"""Carga variables desde .env si existe."""
|
||||
env_file = Path.cwd() / ".env"
|
||||
if not env_file.exists():
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
|
||||
if env_file.exists():
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and value:
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
# Cargar .env al importar
|
||||
load_env()
|
||||
|
||||
|
||||
def get_env(key: str, default: str = "") -> str:
|
||||
"""Obtiene variable de entorno."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||
"""Obtiene variable de entorno como booleano."""
|
||||
val = os.environ.get(key, "").lower()
|
||||
if val in ("true", "yes", "1"):
|
||||
return True
|
||||
if val in ("false", "no", "0"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_env_list(key: str, default: list = None) -> list:
|
||||
"""Obtiene variable de entorno como lista."""
|
||||
val = os.environ.get(key, "")
|
||||
if val:
|
||||
return [x.strip() for x in val.split(",") if x.strip()]
|
||||
return default or []
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Configuración de un servidor."""
|
||||
name: str
|
||||
host: str
|
||||
user: str = "root"
|
||||
key: str = ""
|
||||
description: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.key:
|
||||
self.key = get_env("SSH_KEY_PATH", "~/.ssh/id_rsa")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Configuración de un agente."""
|
||||
name: str
|
||||
role: str
|
||||
provider: str = "claude"
|
||||
model: str = "sonnet"
|
||||
tools: list = field(default_factory=list)
|
||||
servers: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Configuración general."""
|
||||
default_provider: str = "claude"
|
||||
default_model: str = "sonnet"
|
||||
timeout: float = 300.0
|
||||
working_dir: str = "."
|
||||
max_tool_iterations: int = 10
|
||||
|
||||
# Seguridad
|
||||
ssh_strict_host_checking: bool = True
|
||||
sandbox_paths: bool = True # Restringir paths al working_dir
|
||||
allowed_commands: list = field(default_factory=list) # Whitelist de comandos
|
||||
|
||||
# Rate limiting
|
||||
rate_limit_per_minute: int = 60
|
||||
|
||||
# Retry
|
||||
max_retries: int = 3
|
||||
retry_delay: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitConfig:
|
||||
"""Configuración de Gitea/Git."""
|
||||
url: str = ""
|
||||
token_read: str = ""
|
||||
token_write: str = ""
|
||||
org: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
self.url = self.url or get_env("GITEA_URL")
|
||||
self.token_read = self.token_read or get_env("GITEA_TOKEN_READ")
|
||||
self.token_write = self.token_write or get_env("GITEA_TOKEN_WRITE")
|
||||
self.org = self.org or get_env("GITEA_ORG")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.url and self.token_read)
|
||||
|
||||
|
||||
@dataclass
|
||||
class R2Config:
|
||||
"""Configuración de Cloudflare R2."""
|
||||
endpoint: str = ""
|
||||
access_key: str = ""
|
||||
secret_key: str = ""
|
||||
buckets: list = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
self.endpoint = self.endpoint or get_env("R2_ENDPOINT")
|
||||
self.access_key = self.access_key or get_env("R2_ACCESS_KEY")
|
||||
self.secret_key = self.secret_key or get_env("R2_SECRET_KEY")
|
||||
self.buckets = self.buckets or get_env_list("R2_BUCKETS")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.endpoint and self.access_key and self.secret_key)
|
||||
|
||||
|
||||
class Config:
|
||||
"""Gestiona la configuración del orquestador."""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
self.config_path = self._find_config(config_path)
|
||||
self.base_dir = self.config_path.parent if self.config_path else Path.cwd()
|
||||
self._raw = self._load_yaml() if self.config_path else {}
|
||||
|
||||
# Parsear configuración
|
||||
self.settings = self._parse_settings()
|
||||
self.servers = self._parse_servers()
|
||||
self.agents = self._parse_agents()
|
||||
self.tasks = self._raw.get("tasks", {})
|
||||
|
||||
# Servicios externos (desde .env)
|
||||
self.git = GitConfig()
|
||||
self.r2 = R2Config()
|
||||
|
||||
# Rutas
|
||||
self.logs_dir = self.base_dir / "logs"
|
||||
self.outputs_dir = self.base_dir / "outputs"
|
||||
|
||||
# Crear directorios
|
||||
self.logs_dir.mkdir(exist_ok=True)
|
||||
self.outputs_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _find_config(self, config_path: Optional[str]) -> Optional[Path]:
|
||||
"""Encuentra el archivo de configuración."""
|
||||
if config_path:
|
||||
path = Path(config_path)
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError(f"Config no encontrado: {config_path}")
|
||||
|
||||
search_paths = [
|
||||
Path.cwd() / "config.yaml",
|
||||
Path.cwd() / "config.yml",
|
||||
Path(__file__).parent.parent / "config.yaml",
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None # Sin config, usar defaults
|
||||
|
||||
def _load_yaml(self) -> dict:
|
||||
"""Carga el archivo YAML."""
|
||||
if not self.config_path:
|
||||
return {}
|
||||
|
||||
try:
|
||||
import yaml
|
||||
with open(self.config_path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except ImportError:
|
||||
print("AVISO: PyYAML no instalado. pip install pyyaml")
|
||||
return {}
|
||||
|
||||
def _parse_settings(self) -> Settings:
|
||||
"""Parsea la sección settings."""
|
||||
raw = self._raw.get("settings", {})
|
||||
return Settings(
|
||||
default_provider=raw.get("default_provider", "claude"),
|
||||
default_model=raw.get("default_model", "sonnet"),
|
||||
timeout=float(raw.get("timeout", 300)),
|
||||
working_dir=raw.get("working_dir", "."),
|
||||
max_tool_iterations=int(raw.get("max_tool_iterations", 10)),
|
||||
ssh_strict_host_checking=raw.get("ssh_strict_host_checking",
|
||||
get_env_bool("SSH_KNOWN_HOSTS_CHECK", True)),
|
||||
sandbox_paths=raw.get("sandbox_paths", True),
|
||||
allowed_commands=raw.get("allowed_commands", []),
|
||||
rate_limit_per_minute=int(raw.get("rate_limit_per_minute", 60)),
|
||||
max_retries=int(raw.get("max_retries", 3)),
|
||||
retry_delay=float(raw.get("retry_delay", 1.0)),
|
||||
)
|
||||
|
||||
def _parse_servers(self) -> dict[str, ServerConfig]:
|
||||
"""Parsea la sección servers."""
|
||||
servers = {}
|
||||
for name, data in self._raw.get("servers", {}).items():
|
||||
if data:
|
||||
servers[name] = ServerConfig(
|
||||
name=name,
|
||||
host=data.get("host", ""),
|
||||
user=data.get("user", "root"),
|
||||
key=data.get("key", get_env("SSH_KEY_PATH", "~/.ssh/id_rsa")),
|
||||
description=data.get("description", ""),
|
||||
)
|
||||
return servers
|
||||
|
||||
def _parse_agents(self) -> dict[str, AgentConfig]:
|
||||
"""Parsea la sección agents."""
|
||||
agents = {}
|
||||
for name, data in self._raw.get("agents", {}).items():
|
||||
if data:
|
||||
agents[name] = AgentConfig(
|
||||
name=name,
|
||||
role=data.get("role", ""),
|
||||
provider=data.get("provider", self.settings.default_provider),
|
||||
model=data.get("model", self.settings.default_model),
|
||||
tools=data.get("tools", []),
|
||||
servers=data.get("servers", []),
|
||||
)
|
||||
return agents
|
||||
|
||||
def get_agent(self, name: str) -> Optional[AgentConfig]:
|
||||
return self.agents.get(name)
|
||||
|
||||
def get_server(self, name: str) -> Optional[ServerConfig]:
|
||||
return self.servers.get(name)
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return list(self.agents.keys())
|
||||
|
||||
def list_servers(self) -> list[str]:
|
||||
return list(self.servers.keys())
|
||||
|
||||
|
||||
# Instancia global
|
||||
_config: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config(config_path: Optional[str] = None) -> Config:
|
||||
"""Obtiene la configuración global."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config(config_path)
|
||||
return _config
|
||||
|
||||
|
||||
def reload_config(config_path: Optional[str] = None) -> Config:
|
||||
"""Recarga la configuración."""
|
||||
global _config
|
||||
_config = Config(config_path)
|
||||
return _config
|
||||
224
orchestrator/main.py
Normal file
224
orchestrator/main.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# orchestrator/main.py
|
||||
"""
|
||||
LLM Orchestrator - Sistema de orquestación multi-agente seguro.
|
||||
|
||||
Uso:
|
||||
python main.py # Modo interactivo
|
||||
python main.py --status # Ver estado
|
||||
python main.py --agents # Listar agentes
|
||||
python main.py --agent X --prompt "Y" # Ejecutar prompt
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config import get_config, reload_config
|
||||
from agents import Agent, AgentResult
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Orquestador principal."""
|
||||
|
||||
def __init__(self, config_path: str = None):
|
||||
print("Iniciando Orchestrator...")
|
||||
|
||||
self.config = get_config(config_path)
|
||||
self.agents: dict[str, Agent] = {}
|
||||
|
||||
for name, agent_config in self.config.agents.items():
|
||||
try:
|
||||
self.agents[name] = Agent(config_obj=agent_config)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error creando agente {name}: {e}")
|
||||
|
||||
if self.agents:
|
||||
print(f"✅ {len(self.agents)} agente(s): {', '.join(self.agents.keys())}")
|
||||
else:
|
||||
print("⚠️ Sin agentes. Edita config.yaml")
|
||||
|
||||
async def run_agent(self, agent_name: str, prompt: str) -> AgentResult:
|
||||
if agent_name not in self.agents:
|
||||
return AgentResult(
|
||||
success=False, output="",
|
||||
agent_name=agent_name,
|
||||
error=f"Agente '{agent_name}' no existe"
|
||||
)
|
||||
|
||||
return await self.agents[agent_name].run(prompt)
|
||||
|
||||
async def run_all(self, prompt: str) -> dict[str, AgentResult]:
|
||||
"""Ejecuta en todos los agentes EN PARALELO."""
|
||||
tasks = {
|
||||
name: agent.run(prompt)
|
||||
for name, agent in self.agents.items()
|
||||
}
|
||||
|
||||
# Ejecutar en paralelo con gather
|
||||
results_list = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
|
||||
results = {}
|
||||
for name, result in zip(tasks.keys(), results_list):
|
||||
if isinstance(result, Exception):
|
||||
results[name] = AgentResult(
|
||||
success=False, output="",
|
||||
agent_name=name, error=str(result)
|
||||
)
|
||||
else:
|
||||
results[name] = result
|
||||
|
||||
return results
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return {
|
||||
"config_path": str(self.config.config_path) if self.config.config_path else None,
|
||||
"settings": {
|
||||
"timeout": self.config.settings.timeout,
|
||||
"rate_limit": self.config.settings.rate_limit_per_minute,
|
||||
"max_retries": self.config.settings.max_retries,
|
||||
"sandbox_paths": self.config.settings.sandbox_paths,
|
||||
},
|
||||
"agents": {
|
||||
name: {
|
||||
"provider": agent.provider_name,
|
||||
"model": agent.model,
|
||||
"tools": agent.tools,
|
||||
}
|
||||
for name, agent in self.agents.items()
|
||||
},
|
||||
"servers": list(self.config.servers.keys()),
|
||||
"gitea_configured": self.config.git.is_configured,
|
||||
"r2_configured": self.config.r2.is_configured,
|
||||
}
|
||||
|
||||
async def interactive(self):
|
||||
print("\n" + "="*60)
|
||||
print("LLM Orchestrator - Modo Interactivo")
|
||||
print("="*60)
|
||||
print("\nComandos:")
|
||||
print(" /status - Ver estado")
|
||||
print(" /agents - Listar agentes")
|
||||
print(" /agent <n> - Cambiar agente activo")
|
||||
print(" /logs <n> - Ver log de un agente")
|
||||
print(" /reload - Recargar configuración")
|
||||
print(" /all - Ejecutar en todos (paralelo)")
|
||||
print(" /quit - Salir")
|
||||
print("-"*60)
|
||||
|
||||
if not self.agents:
|
||||
print("\n⚠️ No hay agentes. Edita config.yaml")
|
||||
return
|
||||
|
||||
current_agent = list(self.agents.keys())[0]
|
||||
|
||||
while True:
|
||||
try:
|
||||
prompt = input(f"\n[{current_agent}] > ").strip()
|
||||
|
||||
if not prompt:
|
||||
continue
|
||||
|
||||
if prompt == "/quit":
|
||||
print("Saliendo...")
|
||||
break
|
||||
|
||||
elif prompt == "/status":
|
||||
print(json.dumps(self.get_status(), indent=2))
|
||||
|
||||
elif prompt == "/agents":
|
||||
for name, agent in self.agents.items():
|
||||
marker = "→" if name == current_agent else " "
|
||||
print(f" {marker} {name}: {agent.provider_name}/{agent.model}")
|
||||
if agent.tools:
|
||||
print(f" tools: {', '.join(agent.tools)}")
|
||||
|
||||
elif prompt.startswith("/agent "):
|
||||
name = prompt[7:].strip()
|
||||
if name in self.agents:
|
||||
current_agent = name
|
||||
print(f"Agente activo: {current_agent}")
|
||||
else:
|
||||
print(f"No existe '{name}'. Disponibles: {', '.join(self.agents.keys())}")
|
||||
|
||||
elif prompt.startswith("/logs "):
|
||||
name = prompt[6:].strip()
|
||||
if name in self.agents:
|
||||
log = self.agents[name].read_log(last_n=5)
|
||||
print(log if log else "(vacío)")
|
||||
else:
|
||||
print(f"No existe '{name}'")
|
||||
|
||||
elif prompt == "/reload":
|
||||
self.config = reload_config()
|
||||
self.agents = {}
|
||||
for name, agent_config in self.config.agents.items():
|
||||
try:
|
||||
self.agents[name] = Agent(config_obj=agent_config)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error: {name}: {e}")
|
||||
print(f"✅ Recargado: {len(self.agents)} agente(s)")
|
||||
|
||||
elif prompt == "/all":
|
||||
user_prompt = input("Prompt: ").strip()
|
||||
if user_prompt:
|
||||
print("Ejecutando en paralelo...")
|
||||
results = await self.run_all(user_prompt)
|
||||
for name, result in results.items():
|
||||
status = "✅" if result.success else "❌"
|
||||
output = result.output[:200] + "..." if len(result.output) > 200 else result.output
|
||||
print(f"\n{status} {name}:\n{output or result.error}")
|
||||
|
||||
else:
|
||||
print("Ejecutando...")
|
||||
result = await self.run_agent(current_agent, prompt)
|
||||
|
||||
if result.success:
|
||||
print(f"\n{result.output}")
|
||||
else:
|
||||
print(f"\n❌ {result.error}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nUsa /quit para salir.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="LLM Orchestrator")
|
||||
parser.add_argument("--config", type=str, help="Ruta a config.yaml")
|
||||
parser.add_argument("--status", action="store_true", help="Ver estado")
|
||||
parser.add_argument("--agents", action="store_true", help="Listar agentes")
|
||||
parser.add_argument("--agent", type=str, help="Agente a usar")
|
||||
parser.add_argument("--prompt", type=str, help="Prompt a ejecutar")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
orchestrator = Orchestrator(args.config)
|
||||
|
||||
if args.status:
|
||||
print(json.dumps(orchestrator.get_status(), indent=2))
|
||||
|
||||
elif args.agents:
|
||||
for name, agent in orchestrator.agents.items():
|
||||
print(f" {name}: {agent.provider_name}/{agent.model}")
|
||||
|
||||
elif args.agent and args.prompt:
|
||||
result = await orchestrator.run_agent(args.agent, args.prompt)
|
||||
if result.success:
|
||||
print(result.output)
|
||||
else:
|
||||
print(f"Error: {result.error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
await orchestrator.interactive()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
13
orchestrator/providers/__init__.py
Normal file
13
orchestrator/providers/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# orchestrator/providers/__init__.py
|
||||
"""Providers de modelos LLM."""
|
||||
|
||||
from .base import BaseProvider, ProviderResponse
|
||||
from .claude_provider import ClaudeProvider
|
||||
from .litellm_provider import LiteLLMProvider
|
||||
|
||||
__all__ = [
|
||||
"BaseProvider",
|
||||
"ProviderResponse",
|
||||
"ClaudeProvider",
|
||||
"LiteLLMProvider",
|
||||
]
|
||||
146
orchestrator/providers/base.py
Normal file
146
orchestrator/providers/base.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# orchestrator/providers/base.py
|
||||
"""Clase base abstracta para providers de modelos."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Respuesta estándar de cualquier provider."""
|
||||
|
||||
success: bool
|
||||
text: str
|
||||
provider: str
|
||||
model: str
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
error: Optional[str] = None
|
||||
usage: Optional[dict] = None
|
||||
raw_response: Optional[Any] = None
|
||||
tool_calls: list = field(default_factory=list)
|
||||
tool_results: list = field(default_factory=list)
|
||||
retries: int = 0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
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.
|
||||
|
||||
Incluye:
|
||||
- Rate limiting
|
||||
- Retry con backoff exponencial
|
||||
- Manejo de errores consistente
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
timeout: float = 300.0,
|
||||
rate_limit_per_minute: int = 60,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
**kwargs
|
||||
):
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.config = kwargs
|
||||
|
||||
# Rate limiting
|
||||
self.rate_limiter = RateLimiter(rate_limit_per_minute)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Nombre del provider."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def available_models(self) -> list[str]:
|
||||
"""Lista de modelos disponibles."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def supports_native_tools(self) -> bool:
|
||||
"""Indica si soporta function calling nativo."""
|
||||
return False
|
||||
|
||||
async def _retry_with_backoff(self, func, *args, **kwargs) -> tuple[Any, int]:
|
||||
"""Ejecuta con retry y backoff exponencial."""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
# Rate limiting
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
result = await func(*args, **kwargs)
|
||||
return result, attempt
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
delay = min(self.retry_delay * (2 ** attempt), 30.0)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise last_error
|
||||
|
||||
@abstractmethod
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
"""Ejecuta un prompt."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
tools: list[str],
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
"""Ejecuta un prompt con herramientas."""
|
||||
pass
|
||||
|
||||
def validate_model(self, model: str) -> bool:
|
||||
return model in self.available_models
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(model={self.model})"
|
||||
179
orchestrator/providers/claude_provider.py
Normal file
179
orchestrator/providers/claude_provider.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# orchestrator/providers/claude_provider.py
|
||||
"""Provider para Claude usando el CLI de Claude Code."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseProvider, ProviderResponse
|
||||
|
||||
|
||||
class ClaudeProvider(BaseProvider):
|
||||
"""
|
||||
Provider que usa el CLI de Claude Code.
|
||||
|
||||
Ejecuta claude via subprocess, parseando la salida JSON.
|
||||
Funciona con suscripción Pro/Max o API key.
|
||||
"""
|
||||
|
||||
MODELS = {
|
||||
"haiku": "claude-3-5-haiku-latest",
|
||||
"sonnet": "claude-sonnet-4-20250514",
|
||||
"opus": "claude-opus-4-20250514",
|
||||
"fast": "claude-3-5-haiku-latest",
|
||||
"default": "claude-sonnet-4-20250514",
|
||||
"powerful": "claude-opus-4-20250514",
|
||||
}
|
||||
|
||||
AVAILABLE_TOOLS = [
|
||||
"Read", "Write", "Edit", "Bash",
|
||||
"Glob", "Grep", "WebFetch",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "sonnet",
|
||||
timeout: float = 300.0,
|
||||
cli_path: str = "claude",
|
||||
working_directory: Optional[str] = None,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(model=model, timeout=timeout, **kwargs)
|
||||
|
||||
self.cli_path = cli_path
|
||||
self.working_directory = working_directory
|
||||
|
||||
if not shutil.which(self.cli_path):
|
||||
raise FileNotFoundError(
|
||||
f"Claude CLI no encontrado en '{self.cli_path}'. "
|
||||
"Asegúrate de que Claude Code está instalado."
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "claude"
|
||||
|
||||
@property
|
||||
def available_models(self) -> list[str]:
|
||||
return list(self.MODELS.keys())
|
||||
|
||||
@property
|
||||
def supports_native_tools(self) -> bool:
|
||||
return True # Claude CLI tiene herramientas nativas
|
||||
|
||||
def _resolve_model(self, model: str) -> str:
|
||||
return self.MODELS.get(model, model)
|
||||
|
||||
def _build_command(
|
||||
self,
|
||||
prompt: str,
|
||||
tools: Optional[list[str]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
max_turns: Optional[int] = None,
|
||||
) -> list[str]:
|
||||
cmd = [self.cli_path, "-p", prompt, "--output-format", "json"]
|
||||
|
||||
resolved_model = self._resolve_model(self.model)
|
||||
cmd.extend(["--model", resolved_model])
|
||||
|
||||
if system_prompt:
|
||||
cmd.extend(["--system-prompt", system_prompt])
|
||||
|
||||
if tools:
|
||||
valid_tools = [t for t in tools if t in self.AVAILABLE_TOOLS]
|
||||
if valid_tools:
|
||||
cmd.extend(["--allowedTools", ",".join(valid_tools)])
|
||||
|
||||
if max_turns:
|
||||
cmd.extend(["--max-turns", str(max_turns)])
|
||||
|
||||
return cmd
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
prompt: str,
|
||||
tools: list[str] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
max_turns: Optional[int] = None,
|
||||
) -> ProviderResponse:
|
||||
"""Ejecución interna."""
|
||||
cmd = self._build_command(prompt, tools, system_prompt, max_turns)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.working_directory,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else f"Exit code: {process.returncode}"
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=self.model, error=error_msg
|
||||
)
|
||||
|
||||
try:
|
||||
response = json.loads(stdout.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
return ProviderResponse(
|
||||
success=False, text=stdout.decode(),
|
||||
provider=self.name, model=self.model,
|
||||
error=f"Error parseando JSON: {e}"
|
||||
)
|
||||
|
||||
is_error = response.get("is_error", False)
|
||||
|
||||
return ProviderResponse(
|
||||
success=not is_error,
|
||||
text=response.get("result", ""),
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
error=response.get("error") if is_error else None,
|
||||
usage=response.get("usage"),
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
return await self.run_with_tools(prompt, [], system_prompt, **kwargs)
|
||||
|
||||
async def run_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
tools: list[str],
|
||||
system_prompt: Optional[str] = None,
|
||||
max_turns: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
try:
|
||||
result, retries = await self._retry_with_backoff(
|
||||
self._execute, prompt, tools, system_prompt, max_turns
|
||||
)
|
||||
result.retries = retries
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=self.model, error=f"Timeout después de {self.timeout}s"
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=self.model, error=str(e)
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
response = await self.run("Responde solo 'OK'")
|
||||
return response.success and "OK" in response.text
|
||||
250
orchestrator/providers/litellm_provider.py
Normal file
250
orchestrator/providers/litellm_provider.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# orchestrator/providers/litellm_provider.py
|
||||
"""
|
||||
Provider universal usando LiteLLM.
|
||||
|
||||
Soporta 100+ modelos con una interfaz unificada.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseProvider, ProviderResponse
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
class LiteLLMProvider(BaseProvider):
|
||||
"""
|
||||
Provider universal usando LiteLLM.
|
||||
|
||||
Soporta: OpenAI, Google, Anthropic, Mistral, Ollama, Groq, Together, etc.
|
||||
"""
|
||||
|
||||
MODEL_ALIASES = {
|
||||
# OpenAI
|
||||
"gpt4": "gpt-4",
|
||||
"gpt4o": "gpt-4o",
|
||||
"gpt4-turbo": "gpt-4-turbo",
|
||||
"o1": "o1-preview",
|
||||
"o1-mini": "o1-mini",
|
||||
|
||||
# Google Gemini
|
||||
"gemini-pro": "gemini/gemini-1.5-pro",
|
||||
"gemini-flash": "gemini/gemini-1.5-flash",
|
||||
|
||||
# Anthropic (via API)
|
||||
"claude-api": "claude-3-5-sonnet-20241022",
|
||||
"claude-opus-api": "claude-3-opus-20240229",
|
||||
|
||||
# Mistral
|
||||
"mistral": "mistral/mistral-large-latest",
|
||||
"mixtral": "mistral/open-mixtral-8x22b",
|
||||
|
||||
# Ollama (local)
|
||||
"llama3": "ollama/llama3",
|
||||
"codellama": "ollama/codellama",
|
||||
"mixtral-local": "ollama/mixtral",
|
||||
|
||||
# Groq
|
||||
"groq-llama": "groq/llama3-70b-8192",
|
||||
|
||||
# Together
|
||||
"together-llama": "together_ai/meta-llama/Llama-3-70b-chat-hf",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "gpt-4o",
|
||||
timeout: float = 300.0,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
max_tool_iterations: int = 10,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(model=model, timeout=timeout, **kwargs)
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.working_dir = working_dir
|
||||
self.max_tool_iterations = max_tool_iterations
|
||||
self._litellm = None
|
||||
self._tool_executor = None
|
||||
|
||||
def _get_litellm(self):
|
||||
if self._litellm is None:
|
||||
try:
|
||||
import litellm
|
||||
self._litellm = litellm
|
||||
except ImportError:
|
||||
raise ImportError("LiteLLM no instalado. Ejecuta: pip install litellm")
|
||||
return self._litellm
|
||||
|
||||
def _get_tool_executor(self):
|
||||
if self._tool_executor is None:
|
||||
from tools import ToolExecutor
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
self._tool_executor = ToolExecutor(
|
||||
working_dir=self.working_dir or str(config.base_dir),
|
||||
timeout=self.timeout,
|
||||
sandbox_paths=config.settings.sandbox_paths,
|
||||
allowed_commands=config.settings.allowed_commands or None,
|
||||
rate_limit_per_minute=config.settings.rate_limit_per_minute,
|
||||
max_retries=config.settings.max_retries,
|
||||
ssh_strict_host_checking=config.settings.ssh_strict_host_checking,
|
||||
)
|
||||
return self._tool_executor
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "litellm"
|
||||
|
||||
@property
|
||||
def available_models(self) -> list[str]:
|
||||
return list(self.MODEL_ALIASES.keys())
|
||||
|
||||
@property
|
||||
def supports_native_tools(self) -> bool:
|
||||
resolved = self._resolve_model(self.model)
|
||||
return any(x in resolved for x in ["gpt-4", "claude", "gemini"])
|
||||
|
||||
def _resolve_model(self, model: str) -> str:
|
||||
return self.MODEL_ALIASES.get(model, model)
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
messages: list,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
):
|
||||
"""Ejecución interna."""
|
||||
litellm = self._get_litellm()
|
||||
resolved_model = self._resolve_model(self.model)
|
||||
|
||||
completion_kwargs = {
|
||||
"model": resolved_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if max_tokens:
|
||||
completion_kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if self.api_key:
|
||||
completion_kwargs["api_key"] = self.api_key
|
||||
|
||||
if self.api_base:
|
||||
completion_kwargs["api_base"] = self.api_base
|
||||
|
||||
return await litellm.acompletion(**completion_kwargs)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
return await self.run_with_tools(
|
||||
prompt, [], system_prompt, temperature, max_tokens, **kwargs
|
||||
)
|
||||
|
||||
async def run_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
tools: list[str],
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
try:
|
||||
resolved_model = self._resolve_model(self.model)
|
||||
|
||||
# Construir system prompt con herramientas
|
||||
full_system = system_prompt or ""
|
||||
if tools:
|
||||
from tools.definitions import get_tools_prompt
|
||||
tools_prompt = get_tools_prompt(tools)
|
||||
full_system = f"{full_system}\n\n{tools_prompt}"
|
||||
|
||||
messages = []
|
||||
if full_system:
|
||||
messages.append({"role": "system", "content": full_system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Loop de herramientas
|
||||
all_tool_results = []
|
||||
total_retries = 0
|
||||
|
||||
for iteration in range(self.max_tool_iterations):
|
||||
try:
|
||||
response, retries = await self._retry_with_backoff(
|
||||
self._execute, messages, temperature, max_tokens
|
||||
)
|
||||
total_retries += retries
|
||||
except Exception as e:
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=resolved_model, error=f"Error LiteLLM: {e}"
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content or ""
|
||||
|
||||
# Buscar herramientas
|
||||
if tools:
|
||||
executor = self._get_tool_executor()
|
||||
tool_results = await executor.execute_from_text(text)
|
||||
|
||||
if tool_results:
|
||||
all_tool_results.extend(tool_results)
|
||||
|
||||
results_text = "\n\n".join([
|
||||
f"Resultado de {r.tool}: {'✅' if r.success else '❌'}\n{r.output if r.success else r.error}"
|
||||
for r in tool_results
|
||||
])
|
||||
|
||||
messages.append({"role": "assistant", "content": text})
|
||||
messages.append({"role": "user", "content": f"Resultados:\n{results_text}\n\nContinúa."})
|
||||
continue
|
||||
|
||||
# Sin herramientas, terminamos
|
||||
break
|
||||
|
||||
# Extraer uso
|
||||
usage = None
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
usage = {
|
||||
"input_tokens": response.usage.prompt_tokens,
|
||||
"output_tokens": response.usage.completion_tokens,
|
||||
}
|
||||
|
||||
return ProviderResponse(
|
||||
success=True,
|
||||
text=text,
|
||||
provider=self.name,
|
||||
model=resolved_model,
|
||||
usage=usage,
|
||||
tool_results=[r.__dict__ for r in all_tool_results],
|
||||
retries=total_retries,
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=self.model, error=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
return ProviderResponse(
|
||||
success=False, text="", provider=self.name,
|
||||
model=self.model, error=f"Error: {e}"
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
response = await self.run("Responde solo 'OK'", max_tokens=10)
|
||||
return response.success
|
||||
1
orchestrator/tasks/__init__.py
Normal file
1
orchestrator/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tasks predefinidas
|
||||
12
orchestrator/tools/__init__.py
Normal file
12
orchestrator/tools/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# orchestrator/tools/__init__.py
|
||||
"""Sistema de herramientas universal para cualquier LLM."""
|
||||
|
||||
from .executor import ToolExecutor, ToolResult
|
||||
from .definitions import TOOL_DEFINITIONS, get_tool_schema
|
||||
|
||||
__all__ = [
|
||||
"ToolExecutor",
|
||||
"ToolResult",
|
||||
"TOOL_DEFINITIONS",
|
||||
"get_tool_schema",
|
||||
]
|
||||
294
orchestrator/tools/definitions.py
Normal file
294
orchestrator/tools/definitions.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# orchestrator/tools/definitions.py
|
||||
"""
|
||||
Definiciones de herramientas en formato estándar.
|
||||
|
||||
Formato compatible con OpenAI function calling y otros proveedores.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Definiciones de herramientas disponibles
|
||||
TOOL_DEFINITIONS = {
|
||||
"bash": {
|
||||
"name": "bash",
|
||||
"description": "Ejecuta un comando bash en el sistema. Úsalo para ejecutar scripts, comandos del sistema, git, etc.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "El comando bash a ejecutar"
|
||||
},
|
||||
"working_dir": {
|
||||
"type": "string",
|
||||
"description": "Directorio de trabajo opcional"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
|
||||
"read": {
|
||||
"name": "read",
|
||||
"description": "Lee el contenido de un archivo. Devuelve el texto del archivo.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Ruta al archivo a leer"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"description": "Codificación del archivo (default: utf-8)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
},
|
||||
|
||||
"write": {
|
||||
"name": "write",
|
||||
"description": "Escribe contenido a un archivo. Crea el archivo si no existe, sobrescribe si existe.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Ruta al archivo a escribir"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Contenido a escribir en el archivo"
|
||||
},
|
||||
"append": {
|
||||
"type": "boolean",
|
||||
"description": "Si es true, añade al final en vez de sobrescribir"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
}
|
||||
},
|
||||
|
||||
"glob": {
|
||||
"name": "glob",
|
||||
"description": "Busca archivos usando patrones glob. Ej: '**/*.py' encuentra todos los Python.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Patrón glob para buscar archivos"
|
||||
},
|
||||
"base_dir": {
|
||||
"type": "string",
|
||||
"description": "Directorio base para la búsqueda"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
},
|
||||
|
||||
"grep": {
|
||||
"name": "grep",
|
||||
"description": "Busca texto en archivos usando expresiones regulares.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Patrón regex a buscar"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Archivo o directorio donde buscar"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"description": "Buscar recursivamente en subdirectorios"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
}
|
||||
},
|
||||
|
||||
"http_request": {
|
||||
"name": "http_request",
|
||||
"description": "Hace una petición HTTP. Útil para APIs REST.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL a la que hacer la petición"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
"description": "Método HTTP"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "Headers de la petición"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Cuerpo de la petición (para POST, PUT, PATCH)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
|
||||
"ssh": {
|
||||
"name": "ssh",
|
||||
"description": "Ejecuta un comando en un servidor remoto via SSH.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Host o IP del servidor"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Comando a ejecutar en el servidor"
|
||||
},
|
||||
"user": {
|
||||
"type": "string",
|
||||
"description": "Usuario SSH (default: root)"
|
||||
},
|
||||
"key_path": {
|
||||
"type": "string",
|
||||
"description": "Ruta a la clave SSH"
|
||||
}
|
||||
},
|
||||
"required": ["host", "command"]
|
||||
}
|
||||
},
|
||||
|
||||
"list_dir": {
|
||||
"name": "list_dir",
|
||||
"description": "Lista el contenido de un directorio.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Ruta al directorio"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"description": "Listar recursivamente"
|
||||
},
|
||||
"include_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Incluir archivos ocultos"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_tool_schema(tool_names: list[str], format: str = "openai") -> list[dict]:
|
||||
"""
|
||||
Obtiene el schema de herramientas en el formato especificado.
|
||||
|
||||
Args:
|
||||
tool_names: Lista de nombres de herramientas
|
||||
format: Formato de salida (openai, anthropic, gemini)
|
||||
|
||||
Returns:
|
||||
Lista de definiciones de herramientas
|
||||
"""
|
||||
tools = []
|
||||
|
||||
for name in tool_names:
|
||||
if name not in TOOL_DEFINITIONS:
|
||||
continue
|
||||
|
||||
tool_def = TOOL_DEFINITIONS[name]
|
||||
|
||||
if format == "openai":
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_def["name"],
|
||||
"description": tool_def["description"],
|
||||
"parameters": tool_def["parameters"]
|
||||
}
|
||||
})
|
||||
elif format == "anthropic":
|
||||
tools.append({
|
||||
"name": tool_def["name"],
|
||||
"description": tool_def["description"],
|
||||
"input_schema": tool_def["parameters"]
|
||||
})
|
||||
elif format == "gemini":
|
||||
tools.append({
|
||||
"function_declarations": [{
|
||||
"name": tool_def["name"],
|
||||
"description": tool_def["description"],
|
||||
"parameters": tool_def["parameters"]
|
||||
}]
|
||||
})
|
||||
else:
|
||||
# Formato genérico
|
||||
tools.append(tool_def)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_tools_prompt(tool_names: list[str]) -> str:
|
||||
"""
|
||||
Genera un prompt describiendo las herramientas disponibles.
|
||||
|
||||
Para modelos que no soportan function calling nativo,
|
||||
incluimos las herramientas en el prompt.
|
||||
|
||||
Args:
|
||||
tool_names: Lista de nombres de herramientas
|
||||
|
||||
Returns:
|
||||
String con descripción de herramientas para el prompt
|
||||
"""
|
||||
if not tool_names:
|
||||
return ""
|
||||
|
||||
prompt = """
|
||||
## Herramientas disponibles
|
||||
|
||||
Puedes usar las siguientes herramientas. Para usarlas, responde con un bloque JSON así:
|
||||
|
||||
```tool
|
||||
{
|
||||
"tool": "nombre_herramienta",
|
||||
"params": {
|
||||
"param1": "valor1",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Herramientas:
|
||||
|
||||
"""
|
||||
|
||||
for name in tool_names:
|
||||
if name not in TOOL_DEFINITIONS:
|
||||
continue
|
||||
|
||||
tool = TOOL_DEFINITIONS[name]
|
||||
prompt += f"### {tool['name']}\n"
|
||||
prompt += f"{tool['description']}\n"
|
||||
prompt += f"Parámetros: {tool['parameters']['properties']}\n"
|
||||
prompt += f"Requeridos: {tool['parameters'].get('required', [])}\n\n"
|
||||
|
||||
prompt += """
|
||||
Después de usar una herramienta, recibirás el resultado y podrás continuar.
|
||||
Puedes usar múltiples herramientas en secuencia para completar tareas complejas.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
592
orchestrator/tools/executor.py
Normal file
592
orchestrator/tools/executor.py
Normal file
@@ -0,0 +1,592 @@
|
||||
# orchestrator/tools/executor.py
|
||||
"""
|
||||
Ejecutor de herramientas seguro.
|
||||
|
||||
Incluye:
|
||||
- Validación de paths (sandbox)
|
||||
- Sanitización de comandos
|
||||
- Rate limiting
|
||||
- Retry con backoff
|
||||
- Logging de seguridad
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Callable
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""Resultado de la ejecución de una herramienta."""
|
||||
tool: str
|
||||
success: bool
|
||||
output: str
|
||||
error: Optional[str] = None
|
||||
execution_time: float = 0.0
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
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."""
|
||||
|
||||
# Comandos peligrosos que nunca deberían ejecutarse
|
||||
DANGEROUS_COMMANDS = {
|
||||
"rm -rf /", "rm -rf /*", ":(){ :|:& };:", # Fork bomb
|
||||
"dd if=", "mkfs", "fdisk", "> /dev/sd",
|
||||
"chmod -R 777 /", "chown -R",
|
||||
}
|
||||
|
||||
# Patrones peligrosos
|
||||
DANGEROUS_PATTERNS = [
|
||||
r"rm\s+-rf\s+/(?!\w)", # rm -rf / (pero no /home)
|
||||
r">\s*/dev/sd[a-z]", # Escribir a dispositivos
|
||||
r"mkfs\.", # Formatear discos
|
||||
r"dd\s+if=.+of=/dev", # dd a dispositivos
|
||||
]
|
||||
|
||||
def __init__(self, working_dir: Path, sandbox: bool = True, allowed_commands: list = None):
|
||||
self.working_dir = working_dir.resolve()
|
||||
self.sandbox = sandbox
|
||||
self.allowed_commands = allowed_commands or []
|
||||
|
||||
def validate_path(self, path: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Valida que un path esté dentro del sandbox.
|
||||
|
||||
Returns:
|
||||
(is_valid, resolved_path_or_error)
|
||||
"""
|
||||
if not self.sandbox:
|
||||
return True, str(Path(path).expanduser())
|
||||
|
||||
try:
|
||||
resolved = Path(path).expanduser()
|
||||
if not resolved.is_absolute():
|
||||
resolved = self.working_dir / resolved
|
||||
resolved = resolved.resolve()
|
||||
|
||||
# Verificar que está dentro del working_dir
|
||||
try:
|
||||
resolved.relative_to(self.working_dir)
|
||||
return True, str(resolved)
|
||||
except ValueError:
|
||||
return False, f"Path fuera del sandbox: {path}"
|
||||
except Exception as e:
|
||||
return False, f"Path inválido: {e}"
|
||||
|
||||
def validate_command(self, command: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Valida que un comando sea seguro.
|
||||
|
||||
Returns:
|
||||
(is_safe, error_if_unsafe)
|
||||
"""
|
||||
# Verificar comandos exactos peligrosos
|
||||
for dangerous in self.DANGEROUS_COMMANDS:
|
||||
if dangerous in command:
|
||||
return False, f"Comando peligroso detectado: {dangerous}"
|
||||
|
||||
# Verificar patrones peligrosos
|
||||
for pattern in self.DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, command):
|
||||
return False, f"Patrón peligroso detectado: {pattern}"
|
||||
|
||||
# Si hay whitelist, verificar
|
||||
if self.allowed_commands:
|
||||
cmd_name = command.split()[0] if command.split() else ""
|
||||
if cmd_name not in self.allowed_commands:
|
||||
return False, f"Comando no permitido: {cmd_name}"
|
||||
|
||||
return True, ""
|
||||
|
||||
def sanitize_for_shell(self, value: str) -> str:
|
||||
"""Escapa un valor para uso seguro en shell."""
|
||||
return shlex.quote(value)
|
||||
|
||||
|
||||
async def retry_with_backoff(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
):
|
||||
"""
|
||||
Ejecuta una función con retry y backoff exponencial.
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return await func(), attempt
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries:
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise last_error
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""
|
||||
Ejecuta herramientas de forma segura.
|
||||
|
||||
Incluye:
|
||||
- Sandbox de paths
|
||||
- Validación de comandos
|
||||
- Rate limiting
|
||||
- Retry automático
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
working_dir: Optional[str] = None,
|
||||
ssh_key_path: str = "~/.ssh/id_rsa",
|
||||
ssh_strict_host_checking: bool = True,
|
||||
timeout: float = 60.0,
|
||||
allowed_tools: Optional[list[str]] = None,
|
||||
sandbox_paths: bool = True,
|
||||
allowed_commands: Optional[list[str]] = None,
|
||||
rate_limit_per_minute: int = 60,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
):
|
||||
self.working_dir = Path(working_dir).resolve() if working_dir else Path.cwd()
|
||||
self.ssh_key_path = Path(ssh_key_path).expanduser()
|
||||
self.ssh_strict_host_checking = ssh_strict_host_checking
|
||||
self.timeout = timeout
|
||||
self.allowed_tools = allowed_tools
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
|
||||
# Seguridad
|
||||
self.validator = SecurityValidator(
|
||||
self.working_dir,
|
||||
sandbox=sandbox_paths,
|
||||
allowed_commands=allowed_commands
|
||||
)
|
||||
|
||||
# Rate limiting
|
||||
self.rate_limiter = RateLimiter(rate_limit_per_minute)
|
||||
|
||||
# Registro de herramientas
|
||||
self._tools = {
|
||||
"bash": self._exec_bash,
|
||||
"read": self._exec_read,
|
||||
"write": self._exec_write,
|
||||
"glob": self._exec_glob,
|
||||
"grep": self._exec_grep,
|
||||
"http_request": self._exec_http,
|
||||
"ssh": self._exec_ssh,
|
||||
"list_dir": self._exec_list_dir,
|
||||
}
|
||||
|
||||
async def execute(self, tool: str, params: dict) -> ToolResult:
|
||||
"""Ejecuta una herramienta con rate limiting y retry."""
|
||||
start_time = time.time()
|
||||
|
||||
# Verificar si la herramienta está permitida
|
||||
if self.allowed_tools and tool not in self.allowed_tools:
|
||||
return ToolResult(
|
||||
tool=tool, success=False, output="",
|
||||
error=f"Herramienta '{tool}' no permitida"
|
||||
)
|
||||
|
||||
if tool not in self._tools:
|
||||
return ToolResult(
|
||||
tool=tool, success=False, output="",
|
||||
error=f"Herramienta '{tool}' no existe"
|
||||
)
|
||||
|
||||
# Rate limiting
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
# Ejecutar con retry
|
||||
async def do_execute():
|
||||
return await self._tools[tool](params)
|
||||
|
||||
try:
|
||||
result, retries = await retry_with_backoff(
|
||||
do_execute,
|
||||
max_retries=self.max_retries,
|
||||
base_delay=self.retry_delay
|
||||
)
|
||||
result.retries = retries
|
||||
result.execution_time = time.time() - start_time
|
||||
return result
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
tool=tool, success=False, output="",
|
||||
error=str(e),
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
def parse_tool_calls(self, text: str) -> list[dict]:
|
||||
"""
|
||||
Parsea llamadas a herramientas desde el texto.
|
||||
|
||||
Soporta múltiples formatos:
|
||||
- ```tool { ... } ```
|
||||
- ```json { "tool": ... } ```
|
||||
- <tool>...</tool>
|
||||
"""
|
||||
calls = []
|
||||
|
||||
# Formato ```tool ... ```
|
||||
pattern1 = r'```tool\s*\n?(.*?)\n?```'
|
||||
for match in re.findall(pattern1, text, re.DOTALL):
|
||||
try:
|
||||
call = json.loads(match.strip())
|
||||
if "tool" in call:
|
||||
calls.append(call)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Formato ```json ... ``` con tool
|
||||
pattern2 = r'```json\s*\n?(.*?)\n?```'
|
||||
for match in re.findall(pattern2, text, re.DOTALL):
|
||||
try:
|
||||
call = json.loads(match.strip())
|
||||
if "tool" in call:
|
||||
calls.append(call)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Formato <tool name="...">...</tool>
|
||||
pattern3 = r'<tool\s+name=["\'](\w+)["\']>(.*?)</tool>'
|
||||
for name, params_str in re.findall(pattern3, text, re.DOTALL):
|
||||
try:
|
||||
params = json.loads(params_str.strip())
|
||||
calls.append({"tool": name, "params": params})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return calls
|
||||
|
||||
async def execute_from_text(self, text: str) -> list[ToolResult]:
|
||||
"""Ejecuta todas las herramientas encontradas en el texto."""
|
||||
calls = self.parse_tool_calls(text)
|
||||
results = []
|
||||
|
||||
for call in calls:
|
||||
tool = call.get("tool")
|
||||
params = call.get("params", {})
|
||||
result = await self.execute(tool, params)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
# ==================== IMPLEMENTACIÓN DE HERRAMIENTAS ====================
|
||||
|
||||
async def _exec_bash(self, params: dict) -> ToolResult:
|
||||
"""Ejecuta un comando bash de forma segura."""
|
||||
command = params.get("command", "")
|
||||
|
||||
if not command:
|
||||
return ToolResult(tool="bash", success=False, output="", error="Comando vacío")
|
||||
|
||||
# Validar comando
|
||||
is_safe, error = self.validator.validate_command(command)
|
||||
if not is_safe:
|
||||
return ToolResult(tool="bash", success=False, output="", error=error)
|
||||
|
||||
try:
|
||||
# Usar subprocess.run con shell=False cuando sea posible
|
||||
# Para comandos simples, parsear y ejecutar sin shell
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"/bin/bash", "-c", command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(self.working_dir)
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
tool="bash",
|
||||
success=process.returncode == 0,
|
||||
output=stdout.decode(),
|
||||
error=stderr.decode() if process.returncode != 0 else None
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return ToolResult(
|
||||
tool="bash", success=False, output="",
|
||||
error=f"Timeout después de {self.timeout}s"
|
||||
)
|
||||
|
||||
async def _exec_read(self, params: dict) -> ToolResult:
|
||||
"""Lee un archivo dentro del sandbox."""
|
||||
path = params.get("path", "")
|
||||
encoding = params.get("encoding", "utf-8")
|
||||
|
||||
if not path:
|
||||
return ToolResult(tool="read", success=False, output="", error="Path vacío")
|
||||
|
||||
# Validar path
|
||||
is_valid, result = self.validator.validate_path(path)
|
||||
if not is_valid:
|
||||
return ToolResult(tool="read", success=False, output="", error=result)
|
||||
|
||||
try:
|
||||
content = Path(result).read_text(encoding=encoding)
|
||||
return ToolResult(tool="read", success=True, output=content)
|
||||
except FileNotFoundError:
|
||||
return ToolResult(tool="read", success=False, output="", error=f"Archivo no encontrado: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(tool="read", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_write(self, params: dict) -> ToolResult:
|
||||
"""Escribe un archivo dentro del sandbox."""
|
||||
path = params.get("path", "")
|
||||
content = params.get("content", "")
|
||||
append = params.get("append", False)
|
||||
|
||||
if not path:
|
||||
return ToolResult(tool="write", success=False, output="", error="Path vacío")
|
||||
|
||||
# Validar path
|
||||
is_valid, result = self.validator.validate_path(path)
|
||||
if not is_valid:
|
||||
return ToolResult(tool="write", success=False, output="", error=result)
|
||||
|
||||
try:
|
||||
file_path = Path(result)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mode = "a" if append else "w"
|
||||
with open(file_path, mode) as f:
|
||||
f.write(content)
|
||||
|
||||
return ToolResult(
|
||||
tool="write", success=True,
|
||||
output=f"Escrito {len(content)} bytes a {file_path.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(tool="write", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_glob(self, params: dict) -> ToolResult:
|
||||
"""Busca archivos con patrón glob dentro del sandbox."""
|
||||
pattern = params.get("pattern", "")
|
||||
|
||||
if not pattern:
|
||||
return ToolResult(tool="glob", success=False, output="", error="Patrón vacío")
|
||||
|
||||
try:
|
||||
files = list(self.working_dir.glob(pattern))
|
||||
# Filtrar solo archivos dentro del sandbox
|
||||
safe_files = []
|
||||
for f in files[:100]:
|
||||
try:
|
||||
f.relative_to(self.working_dir)
|
||||
safe_files.append(str(f.relative_to(self.working_dir)))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
output = "\n".join(safe_files)
|
||||
return ToolResult(
|
||||
tool="glob", success=True,
|
||||
output=f"Encontrados {len(safe_files)} archivos:\n{output}"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(tool="glob", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_grep(self, params: dict) -> ToolResult:
|
||||
"""Busca texto en archivos."""
|
||||
pattern = params.get("pattern", "")
|
||||
path = params.get("path", "")
|
||||
recursive = params.get("recursive", False)
|
||||
|
||||
if not pattern or not path:
|
||||
return ToolResult(tool="grep", success=False, output="", error="Pattern o path vacío")
|
||||
|
||||
# Validar path
|
||||
is_valid, validated_path = self.validator.validate_path(path)
|
||||
if not is_valid:
|
||||
return ToolResult(tool="grep", success=False, output="", error=validated_path)
|
||||
|
||||
try:
|
||||
cmd = ["grep", "-n", "--color=never"]
|
||||
if recursive:
|
||||
cmd.append("-r")
|
||||
cmd.extend([pattern, validated_path])
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(self.working_dir)
|
||||
)
|
||||
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
return ToolResult(tool="grep", success=True, output=stdout.decode())
|
||||
except Exception as e:
|
||||
return ToolResult(tool="grep", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_http(self, params: dict) -> ToolResult:
|
||||
"""Hace una petición HTTP."""
|
||||
url = params.get("url", "")
|
||||
method = params.get("method", "GET").upper()
|
||||
headers = params.get("headers", {})
|
||||
body = params.get("body", "")
|
||||
|
||||
if not url:
|
||||
return ToolResult(tool="http_request", success=False, output="", error="URL vacía")
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
data = body.encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
|
||||
for key, value in headers.items():
|
||||
req.add_header(key, value)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
||||
content = response.read().decode()
|
||||
return ToolResult(tool="http_request", success=True, output=content)
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
return ToolResult(
|
||||
tool="http_request", success=False,
|
||||
output=e.read().decode() if e.fp else "",
|
||||
error=f"HTTP {e.code}: {e.reason}"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(tool="http_request", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_ssh(self, params: dict) -> ToolResult:
|
||||
"""Ejecuta comando via SSH con verificación de host."""
|
||||
host = params.get("host", "")
|
||||
command = params.get("command", "")
|
||||
user = params.get("user", "root")
|
||||
key_path = params.get("key_path", str(self.ssh_key_path))
|
||||
|
||||
if not host or not command:
|
||||
return ToolResult(tool="ssh", success=False, output="", error="Host o command vacío")
|
||||
|
||||
# Validar comando remoto también
|
||||
is_safe, error = self.validator.validate_command(command)
|
||||
if not is_safe:
|
||||
return ToolResult(tool="ssh", success=False, output="", error=f"Comando remoto inseguro: {error}")
|
||||
|
||||
try:
|
||||
ssh_cmd = [
|
||||
"ssh",
|
||||
"-o", f"StrictHostKeyChecking={'yes' if self.ssh_strict_host_checking else 'no'}",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-i", key_path,
|
||||
f"{user}@{host}",
|
||||
command
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*ssh_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
tool="ssh",
|
||||
success=process.returncode == 0,
|
||||
output=stdout.decode(),
|
||||
error=stderr.decode() if process.returncode != 0 else None
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return ToolResult(
|
||||
tool="ssh", success=False, output="",
|
||||
error=f"SSH timeout después de {self.timeout}s"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(tool="ssh", success=False, output="", error=str(e))
|
||||
|
||||
async def _exec_list_dir(self, params: dict) -> ToolResult:
|
||||
"""Lista contenido de directorio dentro del sandbox."""
|
||||
path = params.get("path", ".")
|
||||
recursive = params.get("recursive", False)
|
||||
include_hidden = params.get("include_hidden", False)
|
||||
|
||||
# Validar path
|
||||
is_valid, validated_path = self.validator.validate_path(path)
|
||||
if not is_valid:
|
||||
return ToolResult(tool="list_dir", success=False, output="", error=validated_path)
|
||||
|
||||
try:
|
||||
dir_path = Path(validated_path)
|
||||
|
||||
if not dir_path.exists():
|
||||
return ToolResult(
|
||||
tool="list_dir", success=False, output="",
|
||||
error=f"Directorio no existe: {path}"
|
||||
)
|
||||
|
||||
entries = []
|
||||
pattern = "**/*" if recursive else "*"
|
||||
|
||||
for entry in dir_path.glob(pattern):
|
||||
if not include_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Verificar que está en sandbox
|
||||
try:
|
||||
entry.relative_to(self.working_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
entry_type = "d" if entry.is_dir() else "f"
|
||||
rel_path = entry.relative_to(dir_path)
|
||||
entries.append(f"[{entry_type}] {rel_path}")
|
||||
|
||||
return ToolResult(
|
||||
tool="list_dir", success=True,
|
||||
output="\n".join(sorted(entries)[:200])
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(tool="list_dir", success=False, output="", error=str(e))
|
||||
0
outputs/.gitkeep
Normal file
0
outputs/.gitkeep
Normal file
Reference in New Issue
Block a user