Initial commit : agent XMPP avec système de skills
- agent1.py : bot XMPP connecté à Ollama avec boucle agentique - skills/web_search.py : recherche DuckDuckGo (ddgs) - skills/web_read.py : lecture et extraction de pages web - skills/memory.py : mémoire persistante SQLite (REMEMBER/RECALL) - skills/loader.py : chargement dynamique des skills
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
config/config.json
|
||||||
|
*.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
venv/
|
||||||
|
myenv/
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from slixmpp import ClientXMPP
|
||||||
|
|
||||||
|
# Ajouter /opt/agent au path pour importer les skills
|
||||||
|
sys.path.insert(0, "/opt/agent")
|
||||||
|
|
||||||
|
from skills.loader import load_skills, run_skills
|
||||||
|
|
||||||
|
# ── CONFIG ───────────────────────────────────────────────────────────────
|
||||||
|
CONFIG_DIR = Path("/opt/agent/config")
|
||||||
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
|
PROMPT_FILE = CONFIG_DIR / "system_prompt.txt"
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def load_system_prompt():
|
||||||
|
with open(PROMPT_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
OLLAMA_URL = cfg["ollama_url"]
|
||||||
|
MODEL = cfg["model"]
|
||||||
|
XMPP_JID = cfg["xmpp_jid"]
|
||||||
|
XMPP_PASS = cfg["xmpp_pass"]
|
||||||
|
ADMIN_JID = cfg["admin_jid"]
|
||||||
|
SYSTEM_PROMPT = load_system_prompt()
|
||||||
|
|
||||||
|
# Charger les skills au démarrage
|
||||||
|
load_skills()
|
||||||
|
|
||||||
|
conversation_history = []
|
||||||
|
|
||||||
|
# ── LLM ──────────────────────────────────────────────────────────────────
|
||||||
|
def call_ollama(messages: list) -> str:
|
||||||
|
payload = {
|
||||||
|
"model" : MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"stream" : False,
|
||||||
|
"options" : {"temperature": 0.3}
|
||||||
|
}
|
||||||
|
response = requests.post(OLLAMA_URL, json=payload, timeout=180)
|
||||||
|
data = response.json()
|
||||||
|
return data["message"]["content"]
|
||||||
|
|
||||||
|
def ask_llm(user_message: str) -> str:
|
||||||
|
conversation_history.append({"role": "user", "content": user_message})
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + conversation_history
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Boucle agentique : le LLM peut enchaîner plusieurs skills
|
||||||
|
MAX_STEPS = 5
|
||||||
|
for _ in range(MAX_STEPS):
|
||||||
|
reply = call_ollama(messages)
|
||||||
|
skill_triggered, result = run_skills(reply)
|
||||||
|
|
||||||
|
if not skill_triggered:
|
||||||
|
# Réponse finale sans commande
|
||||||
|
conversation_history.append({"role": "assistant", "content": reply})
|
||||||
|
return reply
|
||||||
|
|
||||||
|
# Injecter le résultat du skill et relancer le LLM
|
||||||
|
messages.append({"role": "assistant", "content": reply})
|
||||||
|
messages.append({"role": "user", "content": "[Résultat skill]\n" + result})
|
||||||
|
|
||||||
|
# Sécurité : trop d'étapes
|
||||||
|
reply = call_ollama(messages)
|
||||||
|
conversation_history.append({"role": "assistant", "content": reply})
|
||||||
|
return reply
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_reply = "Erreur : " + str(e)
|
||||||
|
conversation_history.append({"role": "assistant", "content": error_reply})
|
||||||
|
return error_reply
|
||||||
|
|
||||||
|
# ── BOT XMPP ─────────────────────────────────────────────────────────────
|
||||||
|
class AgentBot(ClientXMPP):
|
||||||
|
def __init__(self):
|
||||||
|
ClientXMPP.__init__(self, XMPP_JID, XMPP_PASS)
|
||||||
|
self.add_event_handler("session_start", self.session_start)
|
||||||
|
self.add_event_handler("message", self.message)
|
||||||
|
self.register_plugin('xep_0030')
|
||||||
|
self.register_plugin('xep_0199')
|
||||||
|
|
||||||
|
async def session_start(self, event):
|
||||||
|
self.send_presence()
|
||||||
|
await self.get_roster()
|
||||||
|
self.send_message(mto=ADMIN_JID, mbody="Agent en ligne !", mtype='chat')
|
||||||
|
|
||||||
|
async def message(self, msg):
|
||||||
|
if msg['type'] not in ('chat', 'normal'):
|
||||||
|
return
|
||||||
|
if str(msg['from']).split('/')[0] != ADMIN_JID:
|
||||||
|
return
|
||||||
|
|
||||||
|
user_input = msg['body'].strip()
|
||||||
|
|
||||||
|
if user_input == "!reset":
|
||||||
|
conversation_history.clear()
|
||||||
|
self.send_message(mto=ADMIN_JID, mbody="Conversation reinitialisee.", mtype='chat')
|
||||||
|
return
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
reply = await loop.run_in_executor(None, ask_llm, user_input)
|
||||||
|
self.send_message(mto=ADMIN_JID, mbody=reply, mtype='chat')
|
||||||
|
|
||||||
|
# ── MAIN ─────────────────────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
bot = AgentBot()
|
||||||
|
bot.connect()
|
||||||
|
bot.loop.run_forever()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
Tu es un agent autonome de recherche web.
|
||||||
|
Tu peux chercher des informations sur internet et lire des pages web.
|
||||||
|
Tu mémorises les informations importantes pour les réutiliser.
|
||||||
|
|
||||||
|
Formats de commandes disponibles :
|
||||||
|
|
||||||
|
SEARCH: <requête web>
|
||||||
|
→ Recherche web DuckDuckGo (max 5 résultats)
|
||||||
|
|
||||||
|
READ: <url>
|
||||||
|
→ Lire et convertir une page web en markdown
|
||||||
|
|
||||||
|
REMEMBER: <clé> | <valeur>
|
||||||
|
→ Mémoriser une information en base SQLite
|
||||||
|
|
||||||
|
RECALL: <clé>
|
||||||
|
→ Récupérer une information mémorisée
|
||||||
|
|
||||||
|
⚠ RÈGLES ABSOLUES :
|
||||||
|
- Pour toute question sur l'actualité, les événements récents, les prix,
|
||||||
|
les versions de logiciels, les personnes en poste, la météo, ou tout
|
||||||
|
fait pouvant avoir changé : utilise TOUJOURS SEARCH:
|
||||||
|
- Ne JAMAIS répondre de mémoire à une question d'actualité
|
||||||
|
- Commence TOUJOURS par SEARCH: si la question concerne une information
|
||||||
|
datée ou changeante
|
||||||
|
- Ta date de coupure est ancienne : toute info récente DOIT être vérifiée
|
||||||
|
via SEARCH:
|
||||||
|
- Si les résultats de recherche ne sont pas suffisants, utilise READ: sur
|
||||||
|
les URLs prometteuses pour approfondir
|
||||||
|
- Mémorise les informations importantes avec REMEMBER:
|
||||||
|
- Synthétise toujours les informations trouvées de façon claire et concise
|
||||||
|
|
||||||
|
Réponds toujours en français. Sois concis mais précis.
|
||||||
|
Explique ce que tu vas faire avant de le faire.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Chargeur de skills.
|
||||||
|
Détecte les commandes dans une réponse LLM et exécute le skill correspondant.
|
||||||
|
|
||||||
|
Format attendu dans la réponse du LLM :
|
||||||
|
SEARCH: <requête>
|
||||||
|
READ: <url>
|
||||||
|
REMEMBER: <clé> | <valeur>
|
||||||
|
RECALL: <clé>
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SKILLS_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
# Map trigger -> fonction d'exécution
|
||||||
|
_REGISTRY: dict = {}
|
||||||
|
|
||||||
|
def load_skills():
|
||||||
|
"""Charge tous les skills disponibles dans le dossier skills/."""
|
||||||
|
_REGISTRY.clear()
|
||||||
|
|
||||||
|
for py_file in SKILLS_DIR.glob("*.py"):
|
||||||
|
if py_file.name.startswith("_") or py_file.name == "loader.py":
|
||||||
|
continue
|
||||||
|
module_name = "skills.{}".format(py_file.stem)
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(module_name)
|
||||||
|
except Exception as e:
|
||||||
|
print("[Skills] Impossible de charger {} : {}".format(py_file.name, e))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skill avec un seul trigger (ex: SEARCH:, READ:)
|
||||||
|
if hasattr(mod, "TRIGGER") and mod.TRIGGER and hasattr(mod, "execute"):
|
||||||
|
_REGISTRY[mod.TRIGGER] = mod.execute
|
||||||
|
print("[Skills] Chargé : {}".format(mod.TRIGGER))
|
||||||
|
|
||||||
|
# Skill memory : deux triggers
|
||||||
|
if py_file.stem == "memory":
|
||||||
|
if hasattr(mod, "remember"):
|
||||||
|
_REGISTRY["REMEMBER:"] = mod.remember
|
||||||
|
print("[Skills] Chargé : REMEMBER:")
|
||||||
|
if hasattr(mod, "recall"):
|
||||||
|
_REGISTRY["RECALL:"] = mod.recall
|
||||||
|
print("[Skills] Chargé : RECALL:")
|
||||||
|
|
||||||
|
def run_skills(llm_response: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Parcourt la réponse du LLM ligne par ligne.
|
||||||
|
Si une commande est détectée, exécute le skill et retourne (True, résultat).
|
||||||
|
Sinon retourne (False, réponse originale).
|
||||||
|
"""
|
||||||
|
for line in llm_response.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
for trigger, fn in _REGISTRY.items():
|
||||||
|
if line.upper().startswith(trigger):
|
||||||
|
args = line[len(trigger):].strip()
|
||||||
|
result = fn(args)
|
||||||
|
return True, result
|
||||||
|
return False, llm_response
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Skill : REMEMBER / RECALL
|
||||||
|
Mémorise et récupère des informations dans une base SQLite.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SKILL_NAME = "memory"
|
||||||
|
TRIGGER = None # Géré via REMEMBER: et RECALL: séparément dans le loader
|
||||||
|
|
||||||
|
DB_PATH = Path("/opt/agent/memory.db")
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS memory (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def remember(args: str) -> str:
|
||||||
|
if "|" not in args:
|
||||||
|
return "Erreur : format attendu → REMEMBER: <clé> | <valeur>"
|
||||||
|
key, _, value = args.partition("|")
|
||||||
|
key, value = key.strip(), value.strip()
|
||||||
|
if not key or not value:
|
||||||
|
return "Erreur : clé ou valeur vide."
|
||||||
|
try:
|
||||||
|
with _get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR REPLACE INTO memory (key, value) VALUES (?, ?)", (key, value))
|
||||||
|
return "Mémorisé : «{}» = «{}»".format(key, value)
|
||||||
|
except Exception as e:
|
||||||
|
return "Erreur mémoire : {}".format(e)
|
||||||
|
|
||||||
|
def recall(args: str) -> str:
|
||||||
|
key = args.strip()
|
||||||
|
if not key:
|
||||||
|
return "Erreur : clé vide."
|
||||||
|
try:
|
||||||
|
with _get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT value FROM memory WHERE key = ?", (key,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return "Mémoire «{}» : {}".format(key, row[0])
|
||||||
|
return "Aucune information mémorisée pour «{}».".format(key)
|
||||||
|
except Exception as e:
|
||||||
|
return "Erreur mémoire : {}".format(e)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Skill : READ
|
||||||
|
Télécharge une page web et la convertit en texte lisible.
|
||||||
|
"""
|
||||||
|
import urllib.request
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
SKILL_NAME = "read"
|
||||||
|
TRIGGER = "READ:"
|
||||||
|
|
||||||
|
MAX_CHARS = 4000
|
||||||
|
|
||||||
|
def execute(args: str) -> str:
|
||||||
|
url = args.strip()
|
||||||
|
if not url:
|
||||||
|
return "Erreur : URL vide."
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
html = r.read()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
# Supprimer scripts, styles, nav
|
||||||
|
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = soup.get_text(separator="\n")
|
||||||
|
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
||||||
|
content = "\n".join(lines)
|
||||||
|
|
||||||
|
if len(content) > MAX_CHARS:
|
||||||
|
content = content[:MAX_CHARS] + "\n...[tronqué]"
|
||||||
|
|
||||||
|
return "Contenu de {} :\n{}".format(url, content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return "Erreur lors de la lecture de {} : {}".format(url, e)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Skill : SEARCH
|
||||||
|
Effectue une recherche DuckDuckGo et retourne les 5 premiers résultats.
|
||||||
|
"""
|
||||||
|
from ddgs import DDGS
|
||||||
|
|
||||||
|
SKILL_NAME = "search"
|
||||||
|
TRIGGER = "SEARCH:"
|
||||||
|
|
||||||
|
def execute(args: str) -> str:
|
||||||
|
query = args.strip()
|
||||||
|
if not query:
|
||||||
|
return "Erreur : requête vide."
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = list(ddgs.text(query, max_results=5))
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return "Aucun résultat trouvé pour : {}".format(query)
|
||||||
|
|
||||||
|
lines = ["Résultats de recherche pour «{}» :".format(query)]
|
||||||
|
for r in results:
|
||||||
|
lines.append("- **{}**\n {}\n {}".format(r.get("title", ""), r.get("body", ""), r.get("href", "")))
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return "Erreur lors de la recherche : {}".format(e)
|
||||||
Reference in New Issue
Block a user