mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
Backend : - tools.py : read_file / write_file / list_dir confinés au workspace (anti-traversal) - agent.py : boucle de tool-calling itérative au-dessus d'Ollama, génère des événements (token, tool_call, tool_result, final) - routes/chat.py : pilote run_agent, relaie les événements en SSE, persiste la réponse finale + le récap des outils (meta) - routes/files.py : arborescence et contenu du workspace (confiné) - db.py : colonne meta sur messages (+ migration douce), list_messages_for_model Frontend : - ToolCard : carte d'appel d'outil dans le fil (statut en cours/terminé/échec) - streamChat : événements tool_call / tool_result - store : streamTools, fileTree, preview (ouverture auto d'un HTML écrit) - LeftPanel : arborescence réelle du workspace, clic -> aperçu - PreviewPanel : rendu HTML en iframe + onglet Code - ChatPanel : rendu des outils dans les bulles agent (streaming + persisté) Tests manuels : outils + confinement, boucle agent (mock Ollama), chat HTTP de bout en bout (SSE, persistance meta, fichiers écrits, confinement route). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
140 lines
4.7 KiB
Python
140 lines
4.7 KiB
Python
"""Outils de l'agent, exécutés côté serveur et confinés au workspace.
|
|
|
|
Chaque outil expose :
|
|
- une définition JSON (format function-calling Ollama/OpenAI) ;
|
|
- une implémentation Python qui renvoie un dict {ok, summary, ...}.
|
|
|
|
Toutes les opérations fichier sont strictement confinées à WORKSPACE_DIR :
|
|
toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from .config import settings
|
|
|
|
|
|
class ToolError(Exception):
|
|
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
|
|
|
|
|
|
def _workspace_root() -> str:
|
|
root = os.path.abspath(settings.workspace_dir)
|
|
os.makedirs(root, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def _safe_path(rel: str) -> str:
|
|
"""Résout un chemin relatif en restant confiné au workspace."""
|
|
root = _workspace_root()
|
|
target = os.path.abspath(os.path.join(root, rel or "."))
|
|
if target != root and not target.startswith(root + os.sep):
|
|
raise ToolError(f"chemin hors du workspace refusé : {rel}")
|
|
return target
|
|
|
|
|
|
# ── Implémentations ──────────────────────────────────────────────────────
|
|
def read_file(path: str) -> dict:
|
|
target = _safe_path(path)
|
|
if not os.path.isfile(target):
|
|
raise ToolError(f"fichier introuvable : {path}")
|
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
size = os.path.getsize(target)
|
|
summary = "fichier vide (0 octet)" if size == 0 else f"{len(content.splitlines())} lignes lues"
|
|
return {"ok": True, "content": content, "summary": summary}
|
|
|
|
|
|
def write_file(path: str, content: str) -> dict:
|
|
target = _safe_path(path)
|
|
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
|
|
existed = os.path.isfile(target)
|
|
with open(target, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
lines = len(content.splitlines())
|
|
verb = "modifié" if existed else "écrit"
|
|
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
|
|
|
|
|
def list_dir(path: str = ".") -> dict:
|
|
target = _safe_path(path)
|
|
if not os.path.isdir(target):
|
|
raise ToolError(f"répertoire introuvable : {path}")
|
|
entries = []
|
|
for name in sorted(os.listdir(target)):
|
|
full = os.path.join(target, name)
|
|
entries.append({"name": name, "type": "dir" if os.path.isdir(full) else "file"})
|
|
return {
|
|
"ok": True,
|
|
"entries": entries,
|
|
"summary": f"{len(entries)} élément(s)",
|
|
}
|
|
|
|
|
|
# ── Registre & définitions exposées au modèle ────────────────────────────
|
|
TOOL_IMPL = {
|
|
"read_file": read_file,
|
|
"write_file": write_file,
|
|
"list_dir": list_dir,
|
|
}
|
|
|
|
TOOL_DEFINITIONS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_file",
|
|
"description": "Lire le contenu d'un fichier du workspace.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Chemin relatif au workspace"}
|
|
},
|
|
"required": ["path"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "write_file",
|
|
"description": "Créer ou modifier un fichier du workspace.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Chemin relatif au workspace"},
|
|
"content": {"type": "string", "description": "Contenu complet du fichier"},
|
|
},
|
|
"required": ["path", "content"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_dir",
|
|
"description": "Lister le contenu d'un répertoire du workspace.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Répertoire (défaut : racine)"}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def run_tool(name: str, args: dict) -> dict:
|
|
"""Exécute un outil par son nom ; lève ToolError si inconnu/invalide."""
|
|
impl = TOOL_IMPL.get(name)
|
|
if impl is None:
|
|
raise ToolError(f"outil inconnu : {name}")
|
|
try:
|
|
return impl(**(args or {}))
|
|
except ToolError:
|
|
raise
|
|
except TypeError as exc:
|
|
raise ToolError(f"arguments invalides pour {name} : {exc}") from exc
|
|
except OSError as exc:
|
|
raise ToolError(f"erreur système ({name}) : {exc}") from exc
|