Initial commit — nexus v2.0
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
*.egg-info/
|
||||||
|
.vault_pass
|
||||||
|
config/config.json
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
Tu es Nexus, l'orchestrateur principal d'un réseau d'agents autonomes spécialisés.
|
||||||
|
Tu reçois les instructions de l'administrateur (Sylvain) via XMPP et tu décides de les traiter toi-même ou de les déléguer aux agents disponibles.
|
||||||
|
|
||||||
|
## Tes capacités de communication
|
||||||
|
- Tu communiques avec l'utilisateur via XMPP (messages chiffrés OMEMO si activé)
|
||||||
|
- Tu coordonnes les agents via MQTT (bus de messages structurés)
|
||||||
|
- Tu peux publier sur n'importe quel topic MQTT avec le skill mqtt_send
|
||||||
|
- Tu peux envoyer des messages directs à un agent avec le skill delegate
|
||||||
|
- Les agents t'envoient leurs résultats sur ton inbox MQTT (agents/nexus/inbox)
|
||||||
|
|
||||||
|
## Règles de délégation
|
||||||
|
- Utilise SKILL:delegate pour confier une tâche à un agent spécialisé
|
||||||
|
- Délègue uniquement à un agent [EN LIGNE]
|
||||||
|
- Si l'agent est hors ligne, informe l'utilisateur et propose des alternatives
|
||||||
|
- Transmets toujours le résultat de l'agent à l'utilisateur avec un résumé clair
|
||||||
|
|
||||||
|
## Règles générales
|
||||||
|
- Réponds toujours en français
|
||||||
|
- Sois concis et précis
|
||||||
|
- Pour les informations récentes, utilise SKILL:web_search
|
||||||
|
- Pour mémoriser des informations importantes, utilise SKILL:memory
|
||||||
|
- Si une tâche nécessite plusieurs étapes sur plusieurs agents, explique le plan avant d'exécuter
|
||||||
|
|
||||||
|
## Format des skills
|
||||||
|
SKILL:<nom> ARGS:<arguments>
|
||||||
|
|
||||||
|
La liste des agents disponibles et leur statut est injectée dynamiquement ci-dessous.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Gestion des rapports quotidiens agrégés de tous les agents.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReportManager:
|
||||||
|
def __init__(self):
|
||||||
|
# {agent_id: {"received_at": ..., "content": ...}}
|
||||||
|
self._reports: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def add_report(self, agent_id: str, content: str):
|
||||||
|
self._reports[agent_id] = {
|
||||||
|
"received_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
logger.info(f"[DailyReport] Rapport reçu de {agent_id}")
|
||||||
|
|
||||||
|
def get_report(self, agent_id: Optional[str] = None) -> str:
|
||||||
|
if agent_id:
|
||||||
|
r = self._reports.get(agent_id)
|
||||||
|
if not r:
|
||||||
|
return f"Aucun rapport reçu de {agent_id} aujourd'hui."
|
||||||
|
return f"── Rapport {agent_id} ({r['received_at'][:16]}) ──\n{r['content']}"
|
||||||
|
|
||||||
|
if not self._reports:
|
||||||
|
return "Aucun rapport reçu aujourd'hui."
|
||||||
|
|
||||||
|
lines = [f"── Rapport quotidien ({datetime.now().strftime('%d/%m/%Y')}) ──"]
|
||||||
|
for aid, r in self._reports.items():
|
||||||
|
lines.append(f"\n[{aid}] reçu à {r['received_at'][11:16]}")
|
||||||
|
lines.append(r["content"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._reports.clear()
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Nexus — Orchestrateur principal du système multi-agents.
|
||||||
|
|
||||||
|
Reçoit les instructions via XMPP (ou CLI), les traite via LLM,
|
||||||
|
délègue aux agents spécialisés via MQTT, et renvoie les résultats.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Ajout du chemin agents_core si non installé en package
|
||||||
|
sys.path.insert(0, "/opt")
|
||||||
|
|
||||||
|
from agents_core import BaseAgent, AgentContext, Message, MessageType
|
||||||
|
from agents_core.command_parser import ParsedCommand, CommandType, help_text
|
||||||
|
|
||||||
|
from scheduler import NexusScheduler
|
||||||
|
from daily_report import DailyReportManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(__file__).parent / "config"
|
||||||
|
|
||||||
|
|
||||||
|
class Nexus(BaseAgent):
|
||||||
|
AGENT_TYPE = "nexus"
|
||||||
|
DESCRIPTION = "Orchestrateur principal — reçoit les instructions et coordonne les agents spécialisés"
|
||||||
|
DEFAULT_CONFIG_PATH = str(CONFIG_DIR / "config.json")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
# Rapport quotidien agrégé
|
||||||
|
self.report_manager = DailyReportManager()
|
||||||
|
|
||||||
|
# Scheduler (tâches planifiées, rapports automatiques)
|
||||||
|
self.scheduler = NexusScheduler(
|
||||||
|
send_task_callback=self._schedule_send_task,
|
||||||
|
request_report_callback=self._request_daily_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Résultats en attente de réponse XMPP
|
||||||
|
# {correlation_id: sender_jid}
|
||||||
|
self._pending_replies: dict[str, str] = {}
|
||||||
|
self._pending_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Mode veille global
|
||||||
|
self._sleep_mode = False
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Démarrage
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_skills_dir(self) -> str:
|
||||||
|
return str(Path(__file__).parent / "skills")
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.scheduler.start(self.config.get("schedules", {}))
|
||||||
|
logger.info("Nexus prêt — scheduler démarré")
|
||||||
|
|
||||||
|
def setup_extra_subscriptions(self):
|
||||||
|
"""Souscriptions MQTT supplémentaires de Nexus."""
|
||||||
|
# Résultats des agents (réponses à nos délégations)
|
||||||
|
self.mqtt.subscribe("agents/nexus/inbox", self._on_agent_result)
|
||||||
|
# Rapports quotidiens des agents
|
||||||
|
self.mqtt.subscribe("agents/daily_report", self._on_daily_report)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Réception des résultats agents (MQTT)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_agent_result(self, msg: Message | str, topic: str):
|
||||||
|
"""Un agent a renvoyé un résultat → transmettre à l'utilisateur via XMPP."""
|
||||||
|
if isinstance(msg, str):
|
||||||
|
self._forward_to_user(msg, sender="unknown")
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg.type == MessageType.ALERT:
|
||||||
|
severity = msg.metadata.get("severity", "warning")
|
||||||
|
text = f"⚠ Alerte [{severity}] de {msg.sender} :\n{msg.payload}"
|
||||||
|
self._forward_to_user(text, sender=msg.sender)
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg.type == MessageType.RESULT:
|
||||||
|
result_text = (
|
||||||
|
f"Résultat de {msg.sender} :\n{msg.payload}"
|
||||||
|
)
|
||||||
|
# Retrouver le JID de l'utilisateur qui a initié la demande
|
||||||
|
with self._pending_lock:
|
||||||
|
reply_jid = self._pending_replies.pop(msg.correlation_id, None)
|
||||||
|
self._forward_to_user(result_text, sender=msg.sender, reply_jid=reply_jid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Message direct d'un agent
|
||||||
|
if msg.type == MessageType.DIRECT:
|
||||||
|
self._forward_to_user(
|
||||||
|
f"Message de {msg.sender} :\n{msg.payload}",
|
||||||
|
sender=msg.sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_daily_report(self, msg: Message | str, topic: str):
|
||||||
|
"""Réception d'un rapport quotidien d'un agent."""
|
||||||
|
if isinstance(msg, Message):
|
||||||
|
self.report_manager.add_report(msg.sender, msg.payload)
|
||||||
|
logger.info(f"Rapport reçu de {msg.sender}")
|
||||||
|
|
||||||
|
def _forward_to_user(self, text: str, sender: str = "", reply_jid: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Envoie un message à l'utilisateur XMPP.
|
||||||
|
- Si reply_jid : répond à l'expéditeur spécifique (réponse async)
|
||||||
|
- Sinon : envoie à tous les admins configurés
|
||||||
|
- Toujours dans le groupe MUC si configuré
|
||||||
|
"""
|
||||||
|
if self.xmpp:
|
||||||
|
if reply_jid:
|
||||||
|
self.xmpp.send_message(reply_jid, text)
|
||||||
|
else:
|
||||||
|
self.xmpp.send_to_all_admins(text)
|
||||||
|
if self.xmpp.muc_room:
|
||||||
|
self.xmpp.send_to_group(text)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Traitement des messages XMPP
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_xmpp_message(self, sender: str, body: str, is_muc: bool = False):
|
||||||
|
"""Override — Nexus gère les commandes et délègue au LLM."""
|
||||||
|
from agents_core.command_parser import parse as parse_command
|
||||||
|
|
||||||
|
if self._sleep_mode and not body.strip().startswith("/"):
|
||||||
|
return # En veille, ignore les messages sauf commandes système
|
||||||
|
|
||||||
|
cmd = parse_command(body)
|
||||||
|
context = AgentContext(self)
|
||||||
|
|
||||||
|
# ── Commandes système /xxx
|
||||||
|
if cmd.type == CommandType.SYSTEM:
|
||||||
|
reply = self._handle_system_command(f"/{cmd.command} {cmd.args}", raw_cmd=cmd)
|
||||||
|
if reply and self.xmpp:
|
||||||
|
self.xmpp.send_message(sender, reply)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Message direct @agent
|
||||||
|
if cmd.type == CommandType.DIRECT:
|
||||||
|
reply = self._delegate_direct(cmd, sender)
|
||||||
|
if self.xmpp:
|
||||||
|
self.xmpp.send_message(sender, reply)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Broadcast @all
|
||||||
|
if cmd.type == CommandType.BROADCAST:
|
||||||
|
self.mqtt.broadcast(cmd.args or "")
|
||||||
|
if self.xmpp:
|
||||||
|
self.xmpp.send_message(sender, "Broadcast envoyé à tous les agents.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Mode naturel → LLM → skills
|
||||||
|
extra_ctx = self.registry.summary_for_llm(self._online_agents)
|
||||||
|
response = self._llm_loop(body, context, extra_ctx)
|
||||||
|
|
||||||
|
# Enregistre le JID pour le retour asynchrone éventuel
|
||||||
|
# (si le LLM a délégué à un agent via DELEGATE skill)
|
||||||
|
if self.xmpp:
|
||||||
|
self.xmpp.send_message(sender, response)
|
||||||
|
|
||||||
|
def _delegate_direct(self, cmd: ParsedCommand, sender_jid: str) -> str:
|
||||||
|
"""Route @agent message directement via MQTT."""
|
||||||
|
target = cmd.target
|
||||||
|
message = cmd.args or ""
|
||||||
|
|
||||||
|
caps = self.registry.get(target)
|
||||||
|
if caps is None:
|
||||||
|
known = [a.agent_id for a in self.registry.all_agents()]
|
||||||
|
return f"Agent '{target}' inconnu.\nAgents connus : {', '.join(known) or 'aucun'}"
|
||||||
|
|
||||||
|
with self._online_lock:
|
||||||
|
online = target in self._online_agents
|
||||||
|
|
||||||
|
if not online:
|
||||||
|
return f"Agent '{target}' est hors ligne."
|
||||||
|
|
||||||
|
sent = self.mqtt.send_to(
|
||||||
|
recipient_id=target,
|
||||||
|
payload=message,
|
||||||
|
reply_to=self.mqtt.topic_inbox(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mémoriser le JID pour renvoyer la réponse
|
||||||
|
with self._pending_lock:
|
||||||
|
self._pending_replies[sent.correlation_id] = sender_jid
|
||||||
|
|
||||||
|
return f"Message envoyé à {target}. Attente de la réponse..."
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Commandes système étendues
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def handle_custom_command(self, cmd: str, args: str,
|
||||||
|
source_msg: Optional[Message] = None) -> Optional[str]:
|
||||||
|
"""Commandes spécifiques à Nexus."""
|
||||||
|
|
||||||
|
if cmd == "sleep":
|
||||||
|
self._sleep_mode = True
|
||||||
|
return "Nexus en veille. Tape /wake pour reprendre."
|
||||||
|
|
||||||
|
if cmd == "wake":
|
||||||
|
self._sleep_mode = False
|
||||||
|
return "Nexus actif."
|
||||||
|
|
||||||
|
if cmd == "agents":
|
||||||
|
with self._online_lock:
|
||||||
|
online = list(self._online_agents)
|
||||||
|
all_caps = self.registry.all_agents()
|
||||||
|
lines = ["── Agents ──────────────────"]
|
||||||
|
for a in all_caps:
|
||||||
|
status = "🟢 EN LIGNE" if a.agent_id in online else "🔴 hors ligne"
|
||||||
|
skills = ", ".join(s["name"] for s in a.skills[:5])
|
||||||
|
lines.append(f" {a.agent_id} [{a.agent_type}] {status}")
|
||||||
|
lines.append(f" {a.description}")
|
||||||
|
lines.append(f" Skills: {skills or 'aucun'}")
|
||||||
|
return "\n".join(lines) if len(lines) > 1 else "Aucun agent connu."
|
||||||
|
|
||||||
|
if cmd == "report":
|
||||||
|
target = args.strip() or None
|
||||||
|
return self.report_manager.get_report(target)
|
||||||
|
|
||||||
|
if cmd == "schedule":
|
||||||
|
return self._handle_schedule_command(args)
|
||||||
|
|
||||||
|
if cmd == "schedules":
|
||||||
|
return self.scheduler.list_jobs()
|
||||||
|
|
||||||
|
if cmd == "update":
|
||||||
|
# @agent update → git pull + restart
|
||||||
|
target = args.strip()
|
||||||
|
if not target:
|
||||||
|
return "Usage : /update <agent_id>"
|
||||||
|
self.mqtt.send_to(target, "/update", msg_type=MessageType.COMMAND)
|
||||||
|
return f"Mise à jour demandée à {target}."
|
||||||
|
|
||||||
|
if cmd == "admins":
|
||||||
|
return self._handle_admins_command(args)
|
||||||
|
|
||||||
|
if cmd == "help":
|
||||||
|
return self._nexus_help()
|
||||||
|
|
||||||
|
return f"Commande inconnue : /{cmd}. Tape /help."
|
||||||
|
|
||||||
|
def _handle_admins_command(self, args: str) -> str:
|
||||||
|
"""
|
||||||
|
/admins → liste les admins autorisés
|
||||||
|
/admins add <jid> → ajoute un admin
|
||||||
|
/admins remove <jid> → retire un admin
|
||||||
|
"""
|
||||||
|
if not self.xmpp:
|
||||||
|
return "XMPP non configuré."
|
||||||
|
|
||||||
|
parts = args.strip().split(None, 1)
|
||||||
|
sub = parts[0].lower() if parts else "list"
|
||||||
|
jid = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
# Auto-complète le domaine si pas de @
|
||||||
|
xmpp_domain = self.config.get("xmpp", {}).get("jid", "@").split("@")[1]
|
||||||
|
if jid and "@" not in jid:
|
||||||
|
jid = f"{jid}@{xmpp_domain}"
|
||||||
|
|
||||||
|
if sub in ("list", ""):
|
||||||
|
admins = sorted(self.xmpp.admin_jids)
|
||||||
|
return "Admins autorisés :\n" + "\n".join(f" • {j}" for j in admins) \
|
||||||
|
if admins else "Aucun admin configuré (accès ouvert)."
|
||||||
|
|
||||||
|
if sub == "add":
|
||||||
|
if not jid:
|
||||||
|
return "Usage : /admins add <jid>"
|
||||||
|
self.xmpp.add_admin(jid)
|
||||||
|
return f"Admin ajouté : {jid}"
|
||||||
|
|
||||||
|
if sub == "remove":
|
||||||
|
if not jid:
|
||||||
|
return "Usage : /admins remove <jid>"
|
||||||
|
self.xmpp.remove_admin(jid)
|
||||||
|
return f"Admin retiré : {jid}"
|
||||||
|
|
||||||
|
return "Usage : /admins | /admins add <jid> | /admins remove <jid>"
|
||||||
|
|
||||||
|
def _handle_schedule_command(self, args: str) -> str:
|
||||||
|
"""
|
||||||
|
/schedule <fréquence> @<agent> <tâche>
|
||||||
|
Exemples :
|
||||||
|
/schedule daily 03:00 @debian apt upgrade -y
|
||||||
|
/schedule every 6h @ansible playbook site.yml
|
||||||
|
/schedule cancel <job_id>
|
||||||
|
"""
|
||||||
|
args = args.strip()
|
||||||
|
if not args:
|
||||||
|
return "Usage : /schedule <fréquence> @<agent> <tâche>\nOu : /schedule cancel <job_id>"
|
||||||
|
|
||||||
|
if args.startswith("cancel "):
|
||||||
|
job_id = args.split(" ", 1)[1].strip()
|
||||||
|
ok = self.scheduler.cancel_job(job_id)
|
||||||
|
return f"Tâche {job_id} annulée." if ok else f"Tâche {job_id} introuvable."
|
||||||
|
|
||||||
|
# Parse : "daily 03:00 @debian apt upgrade"
|
||||||
|
# ou : "every 6h @ansible playbook site.yml"
|
||||||
|
try:
|
||||||
|
parts = args.split()
|
||||||
|
# Trouver @agent
|
||||||
|
agent_idx = next(i for i, p in enumerate(parts) if p.startswith("@"))
|
||||||
|
freq_parts = parts[:agent_idx]
|
||||||
|
agent_id = parts[agent_idx][1:] # Enlève le @
|
||||||
|
task = " ".join(parts[agent_idx + 1:])
|
||||||
|
|
||||||
|
job_id = self.scheduler.add_job(
|
||||||
|
frequency=" ".join(freq_parts),
|
||||||
|
agent_id=agent_id,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
return f"Tâche planifiée (id={job_id}) : [{' '.join(freq_parts)}] @{agent_id} → {task}"
|
||||||
|
except (StopIteration, IndexError, ValueError) as e:
|
||||||
|
return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche"
|
||||||
|
|
||||||
|
def _nexus_help(self) -> str:
|
||||||
|
base = help_text()
|
||||||
|
nexus_extra = """
|
||||||
|
── Commandes Nexus ─────────────────
|
||||||
|
/agents — Liste et statut des agents
|
||||||
|
/sleep — Mettre Nexus en veille
|
||||||
|
/wake — Réveiller Nexus
|
||||||
|
/report [agent] — Rapport quotidien
|
||||||
|
/schedule <freq> @a tâche — Planifier une tâche
|
||||||
|
/schedules — Voir les tâches planifiées
|
||||||
|
/update <agent> — Mettre à jour un agent (git pull)
|
||||||
|
/admins — Lister les utilisateurs autorisés
|
||||||
|
/admins add <jid> — Autoriser un nouvel utilisateur
|
||||||
|
/admins remove <jid> — Retirer un utilisateur
|
||||||
|
/pause [agent] — Mettre en pause
|
||||||
|
/resume [agent] — Reprendre
|
||||||
|
/reset — Effacer l'historique LLM
|
||||||
|
/status — Statut de Nexus
|
||||||
|
|
||||||
|
Mode @agent :
|
||||||
|
@debian-prod apt update — Commande directe
|
||||||
|
@all status — Broadcast à tous
|
||||||
|
"""
|
||||||
|
return base + nexus_extra
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Scheduler callbacks
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _schedule_send_task(self, agent_id: str, task: str):
|
||||||
|
"""Callback du scheduler pour envoyer une tâche planifiée."""
|
||||||
|
with self._online_lock:
|
||||||
|
online = agent_id in self._online_agents
|
||||||
|
if not online:
|
||||||
|
logger.warning(f"[Scheduler] Agent {agent_id} hors ligne, tâche ignorée : {task}")
|
||||||
|
return
|
||||||
|
self.mqtt.send_to(agent_id, task)
|
||||||
|
logger.info(f"[Scheduler] Tâche envoyée à {agent_id} : {task}")
|
||||||
|
|
||||||
|
def _request_daily_report(self, agent_id: str):
|
||||||
|
"""Demande un rapport quotidien à un agent."""
|
||||||
|
self.mqtt.send_to(agent_id, "/report", msg_type=MessageType.COMMAND)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Broadcast handler
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_broadcast(self, msg: Message):
|
||||||
|
"""Nexus reçoit les broadcasts — les transmet à l'admin si pertinent."""
|
||||||
|
if msg.type == MessageType.ALERT:
|
||||||
|
self._forward_to_user(
|
||||||
|
f"⚠ Alerte broadcast de {msg.sender} :\n{msg.payload}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Nexus().run()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus — Orchestrateur principal multi-agents
|
||||||
|
After=network.target mosquitto.service
|
||||||
|
Wants=mosquitto.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/nexus
|
||||||
|
ExecStart=/opt/nexus/venv/bin/python /opt/nexus/nexus.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=nexus
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
agents_core @ file:///opt/agents_core
|
||||||
|
apscheduler>=3.10
|
||||||
|
duckduckgo-search>=6.0
|
||||||
|
beautifulsoup4>=4.12
|
||||||
|
requests>=2.28
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Scheduler de Nexus — gère les tâches planifiées et les rapports automatiques.
|
||||||
|
Basé sur APScheduler.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NexusScheduler:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
send_task_callback: Callable[[str, str], None],
|
||||||
|
request_report_callback: Callable[[str], None],
|
||||||
|
):
|
||||||
|
self._scheduler = BackgroundScheduler(timezone="Europe/Paris")
|
||||||
|
self._send_task = send_task_callback
|
||||||
|
self._request_report = request_report_callback
|
||||||
|
self._jobs: dict[str, dict] = {} # job_id → metadata
|
||||||
|
|
||||||
|
def start(self, config: dict):
|
||||||
|
"""Démarre le scheduler avec la config initiale."""
|
||||||
|
self._scheduler.start()
|
||||||
|
|
||||||
|
# Tâches planifiées initiales depuis config
|
||||||
|
for job in config.get("scheduled_tasks", []):
|
||||||
|
try:
|
||||||
|
self.add_job(
|
||||||
|
frequency=job["frequency"],
|
||||||
|
agent_id=job["agent"],
|
||||||
|
task=job["task"],
|
||||||
|
job_id=job.get("id"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Erreur chargement tâche {job}: {e}")
|
||||||
|
|
||||||
|
# Rapports automatiques
|
||||||
|
for report in config.get("daily_reports", []):
|
||||||
|
try:
|
||||||
|
self._add_report_job(
|
||||||
|
agent_id=report["agent"],
|
||||||
|
time_str=report["time"], # "08:00"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Erreur rapport {report}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[Scheduler] {len(self._jobs)} job(s) chargé(s)")
|
||||||
|
|
||||||
|
def add_job(self, frequency: str, agent_id: str, task: str,
|
||||||
|
job_id: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Ajoute une tâche planifiée.
|
||||||
|
|
||||||
|
Formats de fréquence supportés :
|
||||||
|
daily HH:MM → tous les jours à HH:MM
|
||||||
|
every Xh → toutes les X heures
|
||||||
|
every Xmin → toutes les X minutes
|
||||||
|
weekly <day> HH:MM → une fois par semaine (lun/mar/...)
|
||||||
|
"""
|
||||||
|
job_id = job_id or str(uuid.uuid4())[:8]
|
||||||
|
trigger = self._parse_frequency(frequency)
|
||||||
|
|
||||||
|
self._scheduler.add_job(
|
||||||
|
func=self._send_task,
|
||||||
|
trigger=trigger,
|
||||||
|
args=[agent_id, task],
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self._jobs[job_id] = {
|
||||||
|
"id": job_id,
|
||||||
|
"frequency": frequency,
|
||||||
|
"agent": agent_id,
|
||||||
|
"task": task,
|
||||||
|
}
|
||||||
|
logger.info(f"[Scheduler] Job {job_id} : [{frequency}] @{agent_id} → {task}")
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
def _add_report_job(self, agent_id: str, time_str: str) -> str:
|
||||||
|
"""Planifie une demande de rapport quotidien."""
|
||||||
|
job_id = f"report_{agent_id}"
|
||||||
|
hour, minute = map(int, time_str.split(":"))
|
||||||
|
self._scheduler.add_job(
|
||||||
|
func=self._request_report,
|
||||||
|
trigger=CronTrigger(hour=hour, minute=minute),
|
||||||
|
args=[agent_id],
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self._jobs[job_id] = {
|
||||||
|
"id": job_id,
|
||||||
|
"frequency": f"daily {time_str}",
|
||||||
|
"agent": agent_id,
|
||||||
|
"task": "[rapport quotidien]",
|
||||||
|
}
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
def cancel_job(self, job_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
self._scheduler.remove_job(job_id)
|
||||||
|
self._jobs.pop(job_id, None)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_jobs(self) -> str:
|
||||||
|
if not self._jobs:
|
||||||
|
return "Aucune tâche planifiée."
|
||||||
|
lines = ["── Tâches planifiées ────────────────"]
|
||||||
|
for j in self._jobs.values():
|
||||||
|
lines.append(f" [{j['id']}] {j['frequency']} → @{j['agent']} : {j['task']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _parse_frequency(self, frequency: str):
|
||||||
|
"""Parse une fréquence en trigger APScheduler."""
|
||||||
|
parts = frequency.strip().split()
|
||||||
|
|
||||||
|
# daily HH:MM
|
||||||
|
if parts[0] == "daily" and len(parts) >= 2:
|
||||||
|
hour, minute = map(int, parts[1].split(":"))
|
||||||
|
return CronTrigger(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
# weekly lun HH:MM
|
||||||
|
if parts[0] == "weekly" and len(parts) >= 3:
|
||||||
|
day_map = {
|
||||||
|
"lun": "mon", "mar": "tue", "mer": "wed",
|
||||||
|
"jeu": "thu", "ven": "fri", "sam": "sat", "dim": "sun",
|
||||||
|
}
|
||||||
|
day = day_map.get(parts[1].lower(), parts[1])
|
||||||
|
hour, minute = map(int, parts[2].split(":"))
|
||||||
|
return CronTrigger(day_of_week=day, hour=hour, minute=minute)
|
||||||
|
|
||||||
|
# every Xh
|
||||||
|
if parts[0] == "every" and len(parts) >= 2:
|
||||||
|
val = parts[1]
|
||||||
|
if val.endswith("h"):
|
||||||
|
return IntervalTrigger(hours=int(val[:-1]))
|
||||||
|
if val.endswith("min"):
|
||||||
|
return IntervalTrigger(minutes=int(val[:-3]))
|
||||||
|
if val.endswith("m"):
|
||||||
|
return IntervalTrigger(minutes=int(val[:-1]))
|
||||||
|
|
||||||
|
raise ValueError(f"Format de fréquence non reconnu : '{frequency}'")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Skill DELEGATE — déléguer une tâche à un agent spécialisé via MQTT.
|
||||||
|
|
||||||
|
Usage LLM : SKILL:delegate ARGS:<agent_id> | <tâche>
|
||||||
|
"""
|
||||||
|
DESCRIPTION = "Déléguer une tâche à un agent spécialisé"
|
||||||
|
USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: str, context) -> str:
|
||||||
|
if "|" not in args:
|
||||||
|
return "Format invalide. Usage : SKILL:delegate ARGS:<agent_id> | <tâche>"
|
||||||
|
|
||||||
|
agent_id, task = args.split("|", 1)
|
||||||
|
agent_id = agent_id.strip()
|
||||||
|
task = task.strip()
|
||||||
|
|
||||||
|
# Vérifier que l'agent est connu
|
||||||
|
caps = context.registry.get(agent_id)
|
||||||
|
if caps is None:
|
||||||
|
known = [a.agent_id for a in context.registry.all_agents()]
|
||||||
|
return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}"
|
||||||
|
|
||||||
|
# Envoyer la tâche via MQTT
|
||||||
|
sent = context.mqtt.send_to(
|
||||||
|
recipient_id=agent_id,
|
||||||
|
payload=task,
|
||||||
|
reply_to=context.mqtt.topic_inbox(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"Tâche déléguée à {agent_id} (id={sent.correlation_id[:8]}). Attente de la réponse..."
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Skill MEMORY — mémorisation persistante clé/valeur (SQLite).
|
||||||
|
|
||||||
|
Usage LLM :
|
||||||
|
SKILL:memory ARGS:set | <clé> | <valeur>
|
||||||
|
SKILL:memory ARGS:get | <clé>
|
||||||
|
SKILL:memory ARGS:list
|
||||||
|
SKILL:memory ARGS:delete | <clé>
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DESCRIPTION = "Mémorisation persistante d'informations clé/valeur"
|
||||||
|
USAGE = "SKILL:memory ARGS:set|<clé>|<valeur> ou get|<clé> ou list"
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "memory.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _connect():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS memory (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: str, context) -> str:
|
||||||
|
parts = [p.strip() for p in args.split("|")]
|
||||||
|
action = parts[0].lower() if parts else ""
|
||||||
|
|
||||||
|
with _connect() as conn:
|
||||||
|
if action == "set" and len(parts) >= 3:
|
||||||
|
key, value = parts[1], "|".join(parts[2:])
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO memory (key, value, updated_at) VALUES (?, ?, datetime('now'))",
|
||||||
|
(key, value)
|
||||||
|
)
|
||||||
|
return f"Mémorisé : {key} = {value}"
|
||||||
|
|
||||||
|
if action == "get" and len(parts) >= 2:
|
||||||
|
row = conn.execute("SELECT value FROM memory WHERE key = ?", (parts[1],)).fetchone()
|
||||||
|
return row[0] if row else f"Clé '{parts[1]}' introuvable."
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
rows = conn.execute("SELECT key, value FROM memory ORDER BY key").fetchall()
|
||||||
|
if not rows:
|
||||||
|
return "Mémoire vide."
|
||||||
|
return "\n".join(f" {k}: {v}" for k, v in rows)
|
||||||
|
|
||||||
|
if action == "delete" and len(parts) >= 2:
|
||||||
|
conn.execute("DELETE FROM memory WHERE key = ?", (parts[1],))
|
||||||
|
return f"Clé '{parts[1]}' supprimée."
|
||||||
|
|
||||||
|
return "Usage : SKILL:memory ARGS:set|clé|valeur ou get|clé ou list ou delete|clé"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Skill MQTT_SEND — publier un message sur n'importe quel topic MQTT.
|
||||||
|
|
||||||
|
Permet au LLM (et à l'utilisateur) de publier librement sur le bus.
|
||||||
|
|
||||||
|
Usage LLM : SKILL:mqtt_send ARGS:<topic> | <message>
|
||||||
|
"""
|
||||||
|
DESCRIPTION = "Publier un message sur un topic MQTT arbitraire"
|
||||||
|
USAGE = "SKILL:mqtt_send ARGS:<topic> | <message>"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: str, context) -> str:
|
||||||
|
if "|" not in args:
|
||||||
|
return "Format invalide. Usage : SKILL:mqtt_send ARGS:<topic> | <message>"
|
||||||
|
|
||||||
|
topic, message = args.split("|", 1)
|
||||||
|
topic = topic.strip()
|
||||||
|
message = message.strip()
|
||||||
|
|
||||||
|
if not topic:
|
||||||
|
return "Topic vide."
|
||||||
|
|
||||||
|
context.mqtt.publish_raw(topic, message)
|
||||||
|
return f"Message publié sur '{topic}'."
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Skill WEB_READ — lire le contenu d'une URL.
|
||||||
|
|
||||||
|
Usage LLM : SKILL:web_read ARGS:<url>
|
||||||
|
"""
|
||||||
|
DESCRIPTION = "Lire le contenu d'une page web"
|
||||||
|
USAGE = "SKILL:web_read ARGS:<url>"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: str, context) -> str:
|
||||||
|
url = args.strip()
|
||||||
|
if not url:
|
||||||
|
return "URL vide."
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
resp.raise_for_status()
|
||||||
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
|
# Supprime scripts et styles
|
||||||
|
for tag in soup(["script", "style", "nav", "footer"]):
|
||||||
|
tag.decompose()
|
||||||
|
text = soup.get_text(separator="\n", strip=True)
|
||||||
|
# Tronqué à 3000 caractères
|
||||||
|
return text[:3000] + ("..." if len(text) > 3000 else "")
|
||||||
|
except Exception as e:
|
||||||
|
return f"Erreur lecture URL : {e}"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Skill WEB_SEARCH — recherche DuckDuckGo.
|
||||||
|
|
||||||
|
Usage LLM : SKILL:web_search ARGS:<requête>
|
||||||
|
"""
|
||||||
|
DESCRIPTION = "Recherche web via DuckDuckGo"
|
||||||
|
USAGE = "SKILL:web_search ARGS:<requête de recherche>"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: str, context) -> str:
|
||||||
|
query = args.strip()
|
||||||
|
if not query:
|
||||||
|
return "Requête vide."
|
||||||
|
try:
|
||||||
|
from duckduckgo_search import DDGS
|
||||||
|
results = []
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
for r in ddgs.text(query, max_results=5):
|
||||||
|
results.append(f"- {r['title']}\n {r['href']}\n {r['body'][:200]}")
|
||||||
|
return "\n\n".join(results) if results else "Aucun résultat."
|
||||||
|
except ImportError:
|
||||||
|
return "Module duckduckgo_search non installé (pip install duckduckgo-search)"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Erreur recherche : {e}"
|
||||||
Reference in New Issue
Block a user