commit a8727654afc8e93923ff9266c53853715ace7f16 Author: sylvain Date: Sat Mar 7 10:32:33 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9414fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +config/config.json +*.db +__pycache__/ +*.pyc +*.pyo +venv/ +myenv/ +*.bak +*.bak.* diff --git a/agent1.py b/agent1.py new file mode 100644 index 0000000..0764daa --- /dev/null +++ b/agent1.py @@ -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() diff --git a/config/system_prompt.txt b/config/system_prompt.txt new file mode 100644 index 0000000..717d0c4 --- /dev/null +++ b/config/system_prompt.txt @@ -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: + → Recherche web DuckDuckGo (max 5 résultats) + +READ: + → Lire et convertir une page web en markdown + +REMEMBER: | + → Mémoriser une information en base SQLite + +RECALL: + → 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. diff --git a/skills/__init__.py b/skills/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/loader.py b/skills/loader.py new file mode 100644 index 0000000..949eb04 --- /dev/null +++ b/skills/loader.py @@ -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: + READ: + REMEMBER: | + RECALL: +""" +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 diff --git a/skills/memory.py b/skills/memory.py new file mode 100644 index 0000000..eafb571 --- /dev/null +++ b/skills/memory.py @@ -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: | " + 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) diff --git a/skills/web_read.py b/skills/web_read.py new file mode 100644 index 0000000..2d1f524 --- /dev/null +++ b/skills/web_read.py @@ -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) diff --git a/skills/web_search.py b/skills/web_search.py new file mode 100644 index 0000000..0bf5965 --- /dev/null +++ b/skills/web_search.py @@ -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)