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:
sylvain committed 2026-03-07 10:32:33 +00:00
commit a8727654af
8 files changed
+338

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
config/config.json
*.db
__pycache__/
*.pyc
*.pyo
venv/
myenv/
*.bak
*.bak.*
+119
View File
@@ -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()
+34
View File
@@ -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.
View File
Whitespace-only changes.
+61
View File
@@ -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
+49
View File
@@ -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)
+38
View File
@@ -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)
+28
View File
@@ -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)