feat: /claude et /mammouth one-shot API, fix admins persistance et work_hours
- Commandes /claude-apikey, /claude-models, /claude-model, /claude - Commandes /mammouth-apikey, /mammouth-models, /mammouth-model, /mammouth - Clés et modèles persistés dans config.json (apis.claude / apis.mammouth) - B11: _save_admins_to_config() persiste admin_jids dans config.json - B12: delegate.py vérifie work_hours avant délégation - README mis à jour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ systemctl enable --now nexus
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `delegate` | Délègue une tâche à un agent via MQTT |
|
||||
| `delegate` | Délègue une tâche à un agent via MQTT (vérifie les `work_hours` de l'agent cible) |
|
||||
| `agents_status` | Liste les agents en ligne/hors ligne |
|
||||
| `memory` | Mémoire clé/valeur SQLite persistante |
|
||||
| `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) |
|
||||
@@ -80,8 +80,8 @@ Fréquences : daily HH:MM | once HH:MM | every Xh | every Xmin | weekly <jour> H
|
||||
### Administration
|
||||
```
|
||||
/admins — Lister les JIDs autorisés
|
||||
/admins add <jid> — Autoriser un utilisateur
|
||||
/admins remove <jid> — Retirer un utilisateur
|
||||
/admins add <jid> — Autoriser un utilisateur (persistant)
|
||||
/admins remove <jid> — Retirer un utilisateur (persistant)
|
||||
/update <agent> — Demande git pull + restart à un agent
|
||||
/update all — Met à jour tous les agents
|
||||
/report [agent] — Rapport quotidien
|
||||
@@ -89,6 +89,21 @@ Fréquences : daily HH:MM | once HH:MM | every Xh | every Xmin | weekly <jour> H
|
||||
/sleep / /wake — Mettre Nexus en veille / réveiller
|
||||
```
|
||||
|
||||
### APIs externes (one-shot)
|
||||
```
|
||||
/claude-apikey <clé> — Enregistrer la clé API Anthropic
|
||||
/claude-models — Lister les modèles Claude disponibles
|
||||
/claude-model <modèle> — Définir le modèle par défaut (ex: claude-opus-4-6)
|
||||
/claude <prompt> — Appel one-shot à l'API Anthropic
|
||||
|
||||
/mammouth-apikey <clé> — Enregistrer la clé API Mammouth
|
||||
/mammouth-models — Lister les modèles Mammouth disponibles
|
||||
/mammouth-model <modèle> — Définir le modèle par défaut (ex: gpt-4.1)
|
||||
/mammouth <prompt> — Appel one-shot à l'API Mammouth
|
||||
```
|
||||
|
||||
Les clés et modèles sont persistés dans `config.json` sous `apis.claude` et `apis.mammouth`. Mammouth est compatible OpenAI API (`https://api.mammouth.ai/v1`).
|
||||
|
||||
### Routing direct
|
||||
```
|
||||
@debian.local apt update — Commande directe sans passer par le LLM
|
||||
@@ -131,7 +146,11 @@ Nexus envoie une notification XMPP à chaque exécution de script sur n'importe
|
||||
"llm_coordinator": { "max_concurrent": 1 },
|
||||
"use_omemo": true,
|
||||
"use_llm_coordinator": true,
|
||||
"system_prompt": "/opt/nexus/config/system_prompt.txt"
|
||||
"system_prompt": "/opt/nexus/config/system_prompt.txt",
|
||||
"apis": {
|
||||
"claude": { "key": "sk-ant-...", "model": "claude-opus-4-6" },
|
||||
"mammouth": { "key": "...", "model": "gpt-4.1" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -25,4 +25,22 @@ SKILL:delegate ARGS:<agent_id> | <instruction complète>
|
||||
- Sois concis et précis
|
||||
- Si une tâche implique plusieurs agents, explique le plan avant d'exécuter
|
||||
|
||||
## Écriture de scripts bash — règles strictes
|
||||
|
||||
### ❌ Interdit dans les scripts bash
|
||||
- `muc_send`, `mqtt_send`, `shell` et tous les noms de skills — ce ne sont PAS des commandes bash
|
||||
- Les guillemets échappés : écris `"texte"` et non `\"texte\"`
|
||||
|
||||
### ✅ Pour envoyer un message depuis un script
|
||||
Variables injectées automatiquement : `$MQTT_BROKER`, `$MQTT_REPLY_TOPIC`, `$AGENT_ID`
|
||||
|
||||
```bash
|
||||
mosquitto_pub -h "$MQTT_BROKER" -t "$MQTT_REPLY_TOPIC" -m "mon résultat"
|
||||
```
|
||||
|
||||
### ✅ Bonnes pratiques
|
||||
- Commence toujours par `#!/bin/bash` et `set -euo pipefail`
|
||||
- Guillemets doubles autour des variables : `"$VAR"`
|
||||
- Gère les cas d'erreur avec des messages explicites
|
||||
|
||||
---
|
||||
|
||||
@@ -342,6 +342,30 @@ class Nexus(BaseAgent):
|
||||
if cmd == "admins":
|
||||
return self._handle_admins_command(args)
|
||||
|
||||
if cmd == "claude-apikey":
|
||||
return self._set_api_key("claude", args.strip())
|
||||
|
||||
if cmd == "claude-models":
|
||||
return self._list_models("claude")
|
||||
|
||||
if cmd == "claude-model":
|
||||
return self._set_default_model("claude", args.strip())
|
||||
|
||||
if cmd == "claude":
|
||||
return self._call_api("claude", args.strip())
|
||||
|
||||
if cmd == "mammouth-apikey":
|
||||
return self._set_api_key("mammouth", args.strip())
|
||||
|
||||
if cmd == "mammouth-models":
|
||||
return self._list_models("mammouth")
|
||||
|
||||
if cmd == "mammouth-model":
|
||||
return self._set_default_model("mammouth", args.strip())
|
||||
|
||||
if cmd == "mammouth":
|
||||
return self._call_api("mammouth", args.strip())
|
||||
|
||||
if cmd == "help":
|
||||
return self._nexus_help()
|
||||
|
||||
@@ -611,6 +635,20 @@ class Nexus(BaseAgent):
|
||||
except Exception as e:
|
||||
return f"Erreur Ollama : {e}"
|
||||
|
||||
def _save_admins_to_config(self):
|
||||
"""Persiste la liste des admins dans config.json."""
|
||||
import json
|
||||
try:
|
||||
with open(self.DEFAULT_CONFIG_PATH, "r") as f:
|
||||
cfg = json.load(f)
|
||||
cfg.setdefault("xmpp", {})["admin_jids"] = sorted(self.xmpp.admin_jids)
|
||||
# Supprime l'ancienne clé singulière si présente
|
||||
cfg["xmpp"].pop("admin_jid", None)
|
||||
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Impossible de sauvegarder les admins : {e}")
|
||||
|
||||
def _handle_admins_command(self, args: str) -> str:
|
||||
"""
|
||||
/admins → liste les admins autorisés
|
||||
@@ -638,12 +676,14 @@ class Nexus(BaseAgent):
|
||||
if not jid:
|
||||
return "Usage : /admins add <jid>"
|
||||
self.xmpp.add_admin(jid)
|
||||
self._save_admins_to_config()
|
||||
return f"Admin ajouté : {jid}"
|
||||
|
||||
if sub == "remove":
|
||||
if not jid:
|
||||
return "Usage : /admins remove <jid>"
|
||||
self.xmpp.remove_admin(jid)
|
||||
self._save_admins_to_config()
|
||||
return f"Admin retiré : {jid}"
|
||||
|
||||
return "Usage : /admins | /admins add <jid> | /admins remove <jid>"
|
||||
@@ -684,6 +724,131 @@ class Nexus(BaseAgent):
|
||||
except (StopIteration, IndexError, ValueError) as e:
|
||||
return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# APIs externes : Claude / Mammouth
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _set_api_key(self, service: str, key: str) -> str:
|
||||
if not key:
|
||||
return f"Usage : /{service}-apikey <clé>"
|
||||
try:
|
||||
import json
|
||||
with open(self.DEFAULT_CONFIG_PATH) as f:
|
||||
cfg = json.load(f)
|
||||
cfg.setdefault("apis", {}).setdefault(service, {})["key"] = key
|
||||
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
return f"✅ Clé API {service} enregistrée."
|
||||
except Exception as e:
|
||||
return f"❌ Erreur : {e}"
|
||||
|
||||
def _set_default_model(self, service: str, model: str) -> str:
|
||||
if not model:
|
||||
return f"Usage : /{service}-model <nom_du_modèle>"
|
||||
try:
|
||||
import json
|
||||
with open(self.DEFAULT_CONFIG_PATH) as f:
|
||||
cfg = json.load(f)
|
||||
cfg.setdefault("apis", {}).setdefault(service, {})["model"] = model
|
||||
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
return f"✅ Modèle par défaut {service} : {model}"
|
||||
except Exception as e:
|
||||
return f"❌ Erreur : {e}"
|
||||
|
||||
def _get_api_config(self, service: str) -> tuple[str, str]:
|
||||
"""Retourne (api_key, model) depuis config.json."""
|
||||
apis = self.config.get("apis", {})
|
||||
svc = apis.get(service, {})
|
||||
key = svc.get("key", "")
|
||||
defaults = {
|
||||
"claude": "claude-opus-4-6",
|
||||
"mammouth": "gpt-4.1",
|
||||
}
|
||||
model = svc.get("model", defaults.get(service, ""))
|
||||
return key, model
|
||||
|
||||
def _list_models(self, service: str) -> str:
|
||||
import requests
|
||||
key, _ = self._get_api_config(service)
|
||||
if not key:
|
||||
return f"❌ Clé API {service} non configurée. Utilise /{service}-apikey <clé>"
|
||||
|
||||
try:
|
||||
if service == "claude":
|
||||
r = requests.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
models = [m["id"] for m in r.json().get("data", [])]
|
||||
else: # mammouth
|
||||
r = requests.get(
|
||||
"https://api.mammouth.ai/v1/models",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
models = [m["id"] for m in r.json().get("data", [])]
|
||||
|
||||
if not models:
|
||||
return f"Aucun modèle retourné pour {service}."
|
||||
_, current = self._get_api_config(service)
|
||||
lines = [f"Modèles disponibles ({service}) :"]
|
||||
for m in models:
|
||||
marker = " ◀ défaut" if m == current else ""
|
||||
lines.append(f" • {m}{marker}")
|
||||
lines.append(f"\nPour changer : /{service}-model <nom>")
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"❌ Erreur listing modèles {service} : {e}"
|
||||
|
||||
def _call_api(self, service: str, prompt: str) -> str:
|
||||
import requests
|
||||
if not prompt:
|
||||
return f"Usage : /{service} <prompt>"
|
||||
|
||||
key, model = self._get_api_config(service)
|
||||
if not key:
|
||||
return f"❌ Clé API {service} non configurée. Utilise /{service}-apikey <clé>"
|
||||
|
||||
try:
|
||||
if service == "claude":
|
||||
r = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["content"][0]["text"]
|
||||
else: # mammouth (OpenAI-compatible)
|
||||
r = requests.post(
|
||||
"https://api.mammouth.ai/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
return f"❌ Erreur {service} : {e}"
|
||||
|
||||
def _nexus_help(self) -> str:
|
||||
base = help_text()
|
||||
nexus_extra = """
|
||||
@@ -713,6 +878,14 @@ class Nexus(BaseAgent):
|
||||
/resume [agent] — Reprendre
|
||||
/reset — Effacer l'historique LLM
|
||||
/status — Statut de Nexus
|
||||
/claude-apikey <clé> — Enregistrer la clé API Anthropic
|
||||
/claude-models — Lister les modèles Claude disponibles
|
||||
/claude-model <modèle> — Définir le modèle Claude par défaut
|
||||
/claude <prompt> — Appel one-shot Claude (API Anthropic)
|
||||
/mammouth-apikey <clé> — Enregistrer la clé API Mammouth
|
||||
/mammouth-models — Lister les modèles Mammouth disponibles
|
||||
/mammouth-model <modèle> — Définir le modèle Mammouth par défaut
|
||||
/mammouth <prompt> — Appel one-shot Mammouth
|
||||
|
||||
Mode @agent :
|
||||
@debian-prod apt update — Commande directe
|
||||
|
||||
@@ -6,6 +6,7 @@ Usage LLM : SKILL:delegate ARGS:<agent_id> | <tâche>
|
||||
"""
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat"
|
||||
USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
|
||||
@@ -13,6 +14,21 @@ USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
|
||||
TIMEOUT = 120 # secondes max d'attente de la réponse
|
||||
|
||||
|
||||
def _is_within_work_hours(work_hours: str) -> bool:
|
||||
"""Vérifie si l'heure actuelle est dans la plage HH:MM-HH:MM."""
|
||||
try:
|
||||
start_str, end_str = work_hours.strip().split("-")
|
||||
now = datetime.now().time()
|
||||
start = datetime.strptime(start_str.strip(), "%H:%M").time()
|
||||
end = datetime.strptime(end_str.strip(), "%H:%M").time()
|
||||
if start <= end:
|
||||
return start <= now <= end
|
||||
# Plage qui chevauche minuit (ex: 22:00-06:00)
|
||||
return now >= start or now <= end
|
||||
except Exception:
|
||||
return True # En cas de format invalide, on laisse passer
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
if "|" not in args:
|
||||
return "Format invalide. Usage : SKILL:delegate ARGS:<agent_id> | <tâche>"
|
||||
@@ -27,6 +43,12 @@ def run(args: str, context) -> str:
|
||||
known = [a.agent_id for a in context.registry.all_agents()]
|
||||
return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}"
|
||||
|
||||
# Vérifier les horaires de travail
|
||||
work_hours = getattr(caps, "work_hours", "00:00-23:59")
|
||||
if not _is_within_work_hours(work_hours):
|
||||
now_str = datetime.now().strftime("%H:%M")
|
||||
return f"⏰ Agent '{agent_id}' hors horaires ({work_hours}). Heure actuelle : {now_str}."
|
||||
|
||||
# Préparer la réception de la réponse
|
||||
corr_id = str(uuid.uuid4())
|
||||
reply_topic = context.mqtt.topic_results(corr_id)
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ def run(args: str, context) -> str:
|
||||
return "Format : save <nom> | <contenu du script>"
|
||||
name_raw, content = rest.split("|", 1)
|
||||
name = _safe_name(name_raw)
|
||||
content = content.strip().replace("\\n", "\n")
|
||||
content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'")
|
||||
|
||||
if not name:
|
||||
return "Nom de script invalide."
|
||||
|
||||
Reference in New Issue
Block a user