mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 21:18:20 +02:00
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
This commit is contained in:
1 parent
48cbcaafaf
commit
b6bf4edf97
13 files changed
+677
-80
No files matched your search
@@ -0,0 +1,113 @@
|
||||
"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
|
||||
|
||||
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
|
||||
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
|
||||
limite d'itérations). La fonction est un générateur asynchrone d'événements
|
||||
relayés tels quels au client via SSE.
|
||||
|
||||
Événements émis :
|
||||
token {content} — fragment de texte de l'agent
|
||||
tool_call {name, args} — début d'exécution d'un outil
|
||||
tool_result {name, args, summary, status}
|
||||
final {content, tools} — réponse complète + récap des outils
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from .ollama_client import ollama
|
||||
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
|
||||
|
||||
MAX_ITERATIONS = 6
|
||||
|
||||
|
||||
def _parse_args(raw) -> dict:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
async def run_agent(
|
||||
model: str,
|
||||
convo: list[dict],
|
||||
*,
|
||||
options: dict | None = None,
|
||||
tools_enabled: bool = True,
|
||||
) -> AsyncIterator[dict]:
|
||||
tools = TOOL_DEFINITIONS if tools_enabled else None
|
||||
collected: list[dict] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
try:
|
||||
for _ in range(MAX_ITERATIONS):
|
||||
content_buf = ""
|
||||
tool_calls: list[dict] = []
|
||||
|
||||
async for chunk in ollama.chat(
|
||||
model, convo, tools=tools, options=options, stream=True
|
||||
):
|
||||
msg = chunk.get("message", {})
|
||||
token = msg.get("content", "")
|
||||
if token:
|
||||
content_buf += token
|
||||
yield {"type": "token", "content": token}
|
||||
if msg.get("tool_calls"):
|
||||
tool_calls.extend(msg["tool_calls"])
|
||||
if chunk.get("done"):
|
||||
break
|
||||
|
||||
assistant_turn: dict = {"role": "assistant", "content": content_buf}
|
||||
if tool_calls:
|
||||
assistant_turn["tool_calls"] = tool_calls
|
||||
convo.append(assistant_turn)
|
||||
if content_buf.strip():
|
||||
text_parts.append(content_buf.strip())
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Exécution des outils demandés, puis réinjection des résultats.
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
args = _parse_args(fn.get("arguments"))
|
||||
|
||||
yield {"type": "tool_call", "name": name, "args": args}
|
||||
|
||||
try:
|
||||
result = run_tool(name, args)
|
||||
summary = result.get("summary", "terminé")
|
||||
status = "ok"
|
||||
except ToolError as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
summary = str(exc)
|
||||
status = "error"
|
||||
|
||||
record = {"name": name, "args": args, "summary": summary, "status": status}
|
||||
collected.append(record)
|
||||
yield {"type": "tool_result", **record}
|
||||
|
||||
convo.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"content": json.dumps(result, ensure_ascii=False),
|
||||
}
|
||||
)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": "\n\n".join(text_parts).strip(),
|
||||
"tools": collected,
|
||||
}
|
||||
+33
-6
@@ -5,6 +5,7 @@ sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -42,12 +43,17 @@ def init_db() -> None:
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
meta TEXT,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session
|
||||
ON messages(session_id, created_at);
|
||||
"""
|
||||
)
|
||||
# Migration douce : ajoute la colonne meta aux bases antérieures.
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
|
||||
if "meta" not in cols:
|
||||
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
@@ -110,20 +116,33 @@ def touch_session(sid: str) -> None:
|
||||
|
||||
|
||||
# ── Messages ─────────────────────────────────────────────────────────────
|
||||
def add_message(sid: str, role: str, content: str, model: str | None) -> dict:
|
||||
def add_message(
|
||||
sid: str,
|
||||
role: str,
|
||||
content: str,
|
||||
model: str | None,
|
||||
meta: dict | None = None,
|
||||
) -> dict:
|
||||
mid = uuid.uuid4().hex
|
||||
now = _now()
|
||||
meta_json = json.dumps(meta, ensure_ascii=False) if meta else None
|
||||
with _LOCK, _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, session_id, role, content, model, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(mid, sid, role, content, model, now),
|
||||
"INSERT INTO messages (id, session_id, role, content, model, meta, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(mid, sid, role, content, model, meta_json, now),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
|
||||
)
|
||||
return {"id": mid, "session_id": sid, "role": role, "content": content,
|
||||
"model": model, "created_at": now}
|
||||
"model": model, "meta": meta, "created_at": now}
|
||||
|
||||
|
||||
def _row_to_message(row: sqlite3.Row) -> dict:
|
||||
msg = dict(row)
|
||||
msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None
|
||||
return msg
|
||||
|
||||
|
||||
def list_messages(sid: str) -> list[dict]:
|
||||
@@ -132,4 +151,12 @@ def list_messages(sid: str) -> list[dict]:
|
||||
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
|
||||
(sid,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
return [_row_to_message(r) for r in rows]
|
||||
|
||||
|
||||
def list_messages_for_model(sid: str) -> list[dict]:
|
||||
"""Historique épuré (role/content) destiné au contexte du modèle."""
|
||||
return [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in list_messages(sid)
|
||||
]
|
||||
+2
-1
@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db
|
||||
from .config import settings
|
||||
from .routes import chat, models, sessions
|
||||
from .routes import chat, files, models, sessions
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -39,6 +39,7 @@ app.add_middleware(
|
||||
app.include_router(models.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(files.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
+40
-32
@@ -1,29 +1,30 @@
|
||||
"""Route de conversation : streaming token par token depuis Ollama (SSE).
|
||||
"""Route de conversation agentique : boucle d'outils + streaming SSE.
|
||||
|
||||
Flux :
|
||||
1. on enregistre le message utilisateur ;
|
||||
2. on rejoue l'historique de la session vers Ollama en streaming ;
|
||||
3. on relaie chaque token au client (SSE) ;
|
||||
4. on enregistre la réponse complète de l'assistant.
|
||||
2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
|
||||
3. on relaie tokens et événements d'outils au client (SSE) ;
|
||||
4. on enregistre la réponse finale de l'assistant + le récap des outils.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import db
|
||||
from ..agent import run_agent
|
||||
from ..config import settings
|
||||
from ..ollama_client import ollama
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chat"])
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"Tu es Loki, un assistant de développement local. Tu écris du code clair, "
|
||||
"commenté en français, et tu réponds de façon concise et utile."
|
||||
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
||||
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
|
||||
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
|
||||
"concise en français. Après avoir écrit un fichier, propose un aperçu."
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +33,7 @@ class ChatRequest(BaseModel):
|
||||
content: str
|
||||
model: str | None = None
|
||||
options: dict | None = None
|
||||
tools_enabled: bool = True
|
||||
|
||||
|
||||
def _sse(event: str, data: dict) -> str:
|
||||
@@ -46,39 +48,45 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
|
||||
model = req.model or session.get("model") or settings.default_model
|
||||
|
||||
# Premier message : on titre la session avec un extrait.
|
||||
history = db.list_messages(req.session_id)
|
||||
if not history:
|
||||
# Premier message : titre la session avec un extrait.
|
||||
if not db.list_messages(req.session_id):
|
||||
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
|
||||
db.rename_session(req.session_id, title)
|
||||
|
||||
db.add_message(req.session_id, "user", req.content, None)
|
||||
|
||||
# Contexte envoyé à Ollama : invite système + historique complet.
|
||||
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
convo += [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in db.list_messages(req.session_id)
|
||||
]
|
||||
convo += db.list_messages_for_model(req.session_id)
|
||||
|
||||
async def event_stream():
|
||||
yield _sse("start", {"model": model})
|
||||
full = ""
|
||||
try:
|
||||
async for chunk in ollama.chat(
|
||||
model, convo, options=req.options, stream=True
|
||||
):
|
||||
token = chunk.get("message", {}).get("content", "")
|
||||
if token:
|
||||
full += token
|
||||
yield _sse("token", {"content": token})
|
||||
if chunk.get("done"):
|
||||
break
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
yield _sse("error", {"message": str(exc)})
|
||||
final_content = ""
|
||||
tools_meta: list[dict] = []
|
||||
|
||||
if full:
|
||||
db.add_message(req.session_id, "assistant", full, model)
|
||||
yield _sse("done", {"content": full, "model": model})
|
||||
async for ev in run_agent(
|
||||
model, convo, options=req.options, tools_enabled=req.tools_enabled
|
||||
):
|
||||
etype = ev.pop("type")
|
||||
if etype == "token":
|
||||
yield _sse("token", ev)
|
||||
elif etype == "tool_call":
|
||||
yield _sse("tool_call", ev)
|
||||
elif etype == "tool_result":
|
||||
yield _sse("tool_result", ev)
|
||||
elif etype == "error":
|
||||
yield _sse("error", ev)
|
||||
elif etype == "final":
|
||||
final_content = ev["content"]
|
||||
tools_meta = ev["tools"]
|
||||
|
||||
if final_content or tools_meta:
|
||||
db.add_message(
|
||||
req.session_id,
|
||||
"assistant",
|
||||
final_content,
|
||||
model,
|
||||
meta={"tools": tools_meta} if tools_meta else None,
|
||||
)
|
||||
yield _sse("done", {"content": final_content, "tools": tools_meta})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Routes de lecture du workspace (arborescence + contenu d'un fichier)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..config import settings
|
||||
from ..tools import ToolError, _safe_path
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
|
||||
def _tree(path: str) -> list[dict]:
|
||||
"""Arborescence triée (dossiers d'abord) du workspace."""
|
||||
items = []
|
||||
for name in sorted(os.listdir(path)):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
full = os.path.join(path, name)
|
||||
rel = os.path.relpath(full, os.path.abspath(settings.workspace_dir))
|
||||
if os.path.isdir(full):
|
||||
items.append({"name": name, "path": rel, "type": "dir",
|
||||
"children": _tree(full)})
|
||||
else:
|
||||
items.append({"name": name, "path": rel, "type": "file",
|
||||
"size": os.path.getsize(full)})
|
||||
# Dossiers avant fichiers
|
||||
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
|
||||
return items
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_files() -> dict:
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return {"tree": _tree(root)}
|
||||
|
||||
|
||||
@router.get("/content")
|
||||
async def file_content(path: str) -> dict:
|
||||
try:
|
||||
target = _safe_path(path)
|
||||
except ToolError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(404, "fichier introuvable")
|
||||
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||
return {"path": path, "content": f.read()}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user