Files
Loki/backend/app/tools.py
T
MichaelandClaude Opus 4.8 b6bf4edf97 Phase 4 : boucle agentique, outils fichiers et aperçu live
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
2026-06-29 18:55:16 +00:00

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