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 |
|
| 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 |
|
| `agents_status` | Liste les agents en ligne/hors ligne |
|
||||||
| `memory` | Mémoire clé/valeur SQLite persistante |
|
| `memory` | Mémoire clé/valeur SQLite persistante |
|
||||||
| `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) |
|
| `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
|
### Administration
|
||||||
```
|
```
|
||||||
/admins — Lister les JIDs autorisés
|
/admins — Lister les JIDs autorisés
|
||||||
/admins add <jid> — Autoriser un utilisateur
|
/admins add <jid> — Autoriser un utilisateur (persistant)
|
||||||
/admins remove <jid> — Retirer un utilisateur
|
/admins remove <jid> — Retirer un utilisateur (persistant)
|
||||||
/update <agent> — Demande git pull + restart à un agent
|
/update <agent> — Demande git pull + restart à un agent
|
||||||
/update all — Met à jour tous les agents
|
/update all — Met à jour tous les agents
|
||||||
/report [agent] — Rapport quotidien
|
/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
|
/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
|
### Routing direct
|
||||||
```
|
```
|
||||||
@debian.local apt update — Commande directe sans passer par le LLM
|
@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 },
|
"llm_coordinator": { "max_concurrent": 1 },
|
||||||
"use_omemo": true,
|
"use_omemo": true,
|
||||||
"use_llm_coordinator": 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
|
- Sois concis et précis
|
||||||
- Si une tâche implique plusieurs agents, explique le plan avant d'exécuter
|
- 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":
|
if cmd == "admins":
|
||||||
return self._handle_admins_command(args)
|
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":
|
if cmd == "help":
|
||||||
return self._nexus_help()
|
return self._nexus_help()
|
||||||
|
|
||||||
@@ -611,6 +635,20 @@ class Nexus(BaseAgent):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Erreur Ollama : {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:
|
def _handle_admins_command(self, args: str) -> str:
|
||||||
"""
|
"""
|
||||||
/admins → liste les admins autorisés
|
/admins → liste les admins autorisés
|
||||||
@@ -638,12 +676,14 @@ class Nexus(BaseAgent):
|
|||||||
if not jid:
|
if not jid:
|
||||||
return "Usage : /admins add <jid>"
|
return "Usage : /admins add <jid>"
|
||||||
self.xmpp.add_admin(jid)
|
self.xmpp.add_admin(jid)
|
||||||
|
self._save_admins_to_config()
|
||||||
return f"Admin ajouté : {jid}"
|
return f"Admin ajouté : {jid}"
|
||||||
|
|
||||||
if sub == "remove":
|
if sub == "remove":
|
||||||
if not jid:
|
if not jid:
|
||||||
return "Usage : /admins remove <jid>"
|
return "Usage : /admins remove <jid>"
|
||||||
self.xmpp.remove_admin(jid)
|
self.xmpp.remove_admin(jid)
|
||||||
|
self._save_admins_to_config()
|
||||||
return f"Admin retiré : {jid}"
|
return f"Admin retiré : {jid}"
|
||||||
|
|
||||||
return "Usage : /admins | /admins add <jid> | /admins remove <jid>"
|
return "Usage : /admins | /admins add <jid> | /admins remove <jid>"
|
||||||
@@ -684,6 +724,131 @@ class Nexus(BaseAgent):
|
|||||||
except (StopIteration, IndexError, ValueError) as e:
|
except (StopIteration, IndexError, ValueError) as e:
|
||||||
return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche"
|
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:
|
def _nexus_help(self) -> str:
|
||||||
base = help_text()
|
base = help_text()
|
||||||
nexus_extra = """
|
nexus_extra = """
|
||||||
@@ -713,6 +878,14 @@ class Nexus(BaseAgent):
|
|||||||
/resume [agent] — Reprendre
|
/resume [agent] — Reprendre
|
||||||
/reset — Effacer l'historique LLM
|
/reset — Effacer l'historique LLM
|
||||||
/status — Statut de Nexus
|
/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 :
|
Mode @agent :
|
||||||
@debian-prod apt update — Commande directe
|
@debian-prod apt update — Commande directe
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Usage LLM : SKILL:delegate ARGS:<agent_id> | <tâche>
|
|||||||
"""
|
"""
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat"
|
DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat"
|
||||||
USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
|
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
|
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:
|
def run(args: str, context) -> str:
|
||||||
if "|" not in args:
|
if "|" not in args:
|
||||||
return "Format invalide. Usage : SKILL:delegate ARGS:<agent_id> | <tâche>"
|
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()]
|
known = [a.agent_id for a in context.registry.all_agents()]
|
||||||
return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}"
|
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
|
# Préparer la réception de la réponse
|
||||||
corr_id = str(uuid.uuid4())
|
corr_id = str(uuid.uuid4())
|
||||||
reply_topic = context.mqtt.topic_results(corr_id)
|
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>"
|
return "Format : save <nom> | <contenu du script>"
|
||||||
name_raw, content = rest.split("|", 1)
|
name_raw, content = rest.split("|", 1)
|
||||||
name = _safe_name(name_raw)
|
name = _safe_name(name_raw)
|
||||||
content = content.strip().replace("\\n", "\n")
|
content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'")
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return "Nom de script invalide."
|
return "Nom de script invalide."
|
||||||
|
|||||||
Reference in New Issue
Block a user