mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
Niveau supérieur : mémoire compressée, outils chirurgicaux, modèle code auto
Faire d'un petit modèle un bon agent : - memory.py : contexte = invite + résumé des anciens tours + 10 derniers messages ; résumé régénéré en arrière-plan après chaque réponse (colonne summary sur sessions, migration douce). Contexte court = modèle concentré et qui tient sur le GPU. - tools.py : edit_file (recherche/remplacement exact, erreurs pédagogiques, unicité exigée), grep_search (regex bornée, dossiers ignorés), et vérification syntaxique auto (.py/.json) après chaque écriture — l'erreur revient au modèle qui se corrige dans le même tour. - coder.pick_code_model : les tâches de code vont au meilleur modèle code installé (qwen-coder, deepseek-coder…) via config code_model=auto. - Branché dans la route chat (mémoire + résolution du modèle code) et la boucle agent (code_task) ; UI : descriptions et glyphes des nouveaux outils. Tests : edit/grep/vérification, compression mémoire (19 msgs -> 12 dont résumé), pick auto/explicite, chat HTTP de bout en bout, build front. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
11 files changed
+356
-11
No files matched your search
@@ -115,6 +115,25 @@ npm run dev # http://localhost:5173
|
|||||||
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
|
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
|
||||||
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
|
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
|
||||||
|
|
||||||
|
## Tirer le meilleur des petits modèles
|
||||||
|
|
||||||
|
Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent :
|
||||||
|
|
||||||
|
- **Mémoire compressée** : au-delà d'un seuil, les anciens tours sont résumés
|
||||||
|
en arrière-plan et le modèle ne reçoit que « invite + résumé + 10 derniers
|
||||||
|
messages ». Contexte court = modèle concentré, et qui reste sur le GPU.
|
||||||
|
- **Outils chirurgicaux** : `edit_file` (recherche/remplacement exact — pas de
|
||||||
|
réécriture intégrale ratée), `grep_search` (trouver avant de modifier), et
|
||||||
|
`write_file` par morceaux (overwrite/append).
|
||||||
|
- **Auto-vérification** : après chaque écriture, la syntaxe (`.py`, `.json`)
|
||||||
|
est contrôlée ; l'erreur est renvoyée immédiatement au modèle, qui se
|
||||||
|
corrige dans le même tour.
|
||||||
|
- **Bon modèle au bon poste** : les tâches de code sont confiées au meilleur
|
||||||
|
modèle code installé (`qwen-coder`, `deepseek-coder`…), automatiquement
|
||||||
|
(config `code_model: auto`), même si tu discutes avec un généraliste.
|
||||||
|
- **Récupération d'appels d'outils malformés** : arguments réparés ou
|
||||||
|
redemandés, modèles sans function-calling détectés et gérés.
|
||||||
|
|
||||||
## Moteur code (façon Claude Code) — routage automatique
|
## Moteur code (façon Claude Code) — routage automatique
|
||||||
|
|
||||||
Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme
|
Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme
|
||||||
|
|||||||
@@ -264,10 +264,11 @@ async def run_agent(
|
|||||||
|
|
||||||
# code_task : délégué au moteur code (Aider), long -> thread.
|
# code_task : délégué au moteur code (Aider), long -> thread.
|
||||||
if name == "code_task":
|
if name == "code_task":
|
||||||
|
code_model = await coder.pick_code_model(model)
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
coder.run_code_task,
|
coder.run_code_task,
|
||||||
args.get("instruction", ""),
|
args.get("instruction", ""),
|
||||||
model,
|
code_model,
|
||||||
args.get("files") or [],
|
args.get("files") or [],
|
||||||
)
|
)
|
||||||
summary = result.get("summary", "terminé")
|
summary = result.get("summary", "terminé")
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ DEFAULT_SYSTEM_PROMPT = (
|
|||||||
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
|
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
|
||||||
# par défaut, conformément à la maquette.
|
# par défaut, conformément à la maquette.
|
||||||
AVAILABLE_TOOLS = [
|
AVAILABLE_TOOLS = [
|
||||||
"read_file", "write_file", "list_dir", "code_task", "web_search", "run_shell",
|
"read_file", "write_file", "edit_file", "list_dir", "grep_search",
|
||||||
|
"code_task", "web_search", "run_shell",
|
||||||
]
|
]
|
||||||
SENSITIVE_TOOLS = {"run_shell"}
|
SENSITIVE_TOOLS = {"run_shell"}
|
||||||
DEFAULT_TOOL_STATE = {
|
DEFAULT_TOOL_STATE = {
|
||||||
"read_file": True,
|
"read_file": True,
|
||||||
"write_file": True,
|
"write_file": True,
|
||||||
|
"edit_file": True,
|
||||||
"list_dir": True,
|
"list_dir": True,
|
||||||
|
"grep_search": True,
|
||||||
"code_task": True,
|
"code_task": True,
|
||||||
"web_search": False,
|
"web_search": False,
|
||||||
"run_shell": False,
|
"run_shell": False,
|
||||||
@@ -56,6 +59,7 @@ PROFILE_FIELDS = {
|
|||||||
"tools",
|
"tools",
|
||||||
"confirm_shell",
|
"confirm_shell",
|
||||||
"think",
|
"think",
|
||||||
|
"code_model",
|
||||||
*GENERATION_FIELDS,
|
*GENERATION_FIELDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +92,9 @@ DEFAULT_CONFIG: dict = {
|
|||||||
# Mode réflexion des modèles « thinking ». Désactiver (False) évite qu'un
|
# Mode réflexion des modèles « thinking ». Désactiver (False) évite qu'un
|
||||||
# modèle ne renvoie que du raisonnement sans réponse finale.
|
# modèle ne renvoie que du raisonnement sans réponse finale.
|
||||||
"think": True,
|
"think": True,
|
||||||
|
# Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé
|
||||||
|
# (qwen-coder, deepseek-coder…), sinon le modèle de chat courant.
|
||||||
|
"code_model": "auto",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,44 @@ def available() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Familles spécialisées code, par ordre de préférence.
|
||||||
|
_CODE_MODEL_HINTS = (
|
||||||
|
"qwen3-coder", "qwen2.5-coder", "deepseek-coder", "codestral", "devstral",
|
||||||
|
"codegemma", "codellama", "starcoder", "coder",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def pick_code_model(current: str, preference: str | None = None) -> str:
|
||||||
|
"""Choisit le modèle pour les tâches de code.
|
||||||
|
|
||||||
|
- préférence explicite (config code_model != "auto") -> respectée ;
|
||||||
|
- sinon, si un modèle spécialisé code est installé, on le prend (le plus
|
||||||
|
gros d'abord) : un petit modèle code bat un généraliste sur ce terrain ;
|
||||||
|
- sinon, on garde le modèle courant.
|
||||||
|
"""
|
||||||
|
if preference and preference != "auto":
|
||||||
|
return preference
|
||||||
|
|
||||||
|
from .ollama_client import ollama
|
||||||
|
try:
|
||||||
|
installed = await ollama.list_models()
|
||||||
|
except Exception:
|
||||||
|
return current
|
||||||
|
|
||||||
|
candidates: list[tuple[int, int, str]] = [] # (rang_hint, -taille, nom)
|
||||||
|
for m in installed:
|
||||||
|
name = (m.get("name") or "").lower()
|
||||||
|
for rank, hint in enumerate(_CODE_MODEL_HINTS):
|
||||||
|
if hint in name:
|
||||||
|
candidates.append((rank, -(m.get("size") or 0), m["name"]))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return current
|
||||||
|
candidates.sort()
|
||||||
|
return candidates[0][2]
|
||||||
|
|
||||||
|
|
||||||
def run_code_task(
|
def run_code_task(
|
||||||
instruction: str,
|
instruction: str,
|
||||||
model: str,
|
model: str,
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def init_db() -> None:
|
|||||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
|
||||||
if "meta" not in cols:
|
if "meta" not in cols:
|
||||||
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
|
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
|
||||||
|
# Migration douce : résumé de conversation (mémoire compressée).
|
||||||
|
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
|
||||||
|
if "summary" not in scols:
|
||||||
|
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
|
||||||
# Table clé/valeur pour la configuration de l'agent.
|
# Table clé/valeur pour la configuration de l'agent.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
|
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
|
||||||
@@ -166,6 +170,13 @@ def list_messages_for_model(sid: str) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_summary(sid: str, summary: str) -> None:
|
||||||
|
with _LOCK, _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET summary = ? WHERE id = ?", (summary, sid)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Configuration (clé/valeur JSON) ──────────────────────────────────────
|
# ── Configuration (clé/valeur JSON) ──────────────────────────────────────
|
||||||
def get_config_value(key: str) -> dict | None:
|
def get_config_value(key: str) -> dict | None:
|
||||||
with _LOCK, _connect() as conn:
|
with _LOCK, _connect() as conn:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Mémoire de conversation compressée — le vrai levier des petits modèles.
|
||||||
|
|
||||||
|
Un petit modèle se noie dans un long historique : il oublie la consigne, part
|
||||||
|
en boucle, et un grand num_ctx le fait déborder du GPU. On garde donc :
|
||||||
|
[invite système] + [résumé compact des anciens tours] + [N derniers messages]
|
||||||
|
|
||||||
|
Le résumé est régénéré en arrière-plan (après la réponse, sans latence pour
|
||||||
|
l'utilisateur) dès que l'historique dépasse le seuil.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .ollama_client import ollama
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Nombre de messages récents passés tels quels au modèle.
|
||||||
|
KEEP_RECENT = 10
|
||||||
|
# Au-delà de ce total, les anciens tours sont compressés dans le résumé.
|
||||||
|
SUMMARIZE_AFTER = KEEP_RECENT + 6
|
||||||
|
|
||||||
|
_SUMMARY_PROMPT = (
|
||||||
|
"Résume la conversation ci-dessous en français, en 10 lignes maximum. "
|
||||||
|
"Conserve impérativement : l'objectif de l'utilisateur, les décisions "
|
||||||
|
"prises, les fichiers créés/modifiés et leur rôle, et les points encore "
|
||||||
|
"ouverts. Réponds UNIQUEMENT par le résumé."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_convo(sid: str, system_prompt: str) -> list[dict]:
|
||||||
|
"""Construit le contexte : système + résumé éventuel + messages récents."""
|
||||||
|
convo: list[dict] = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
session = db.get_session(sid) or {}
|
||||||
|
summary = (session.get("summary") or "").strip()
|
||||||
|
messages = db.list_messages_for_model(sid)
|
||||||
|
|
||||||
|
if summary and len(messages) > KEEP_RECENT:
|
||||||
|
convo.append(
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": f"Résumé des échanges précédents :\n{summary}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
convo += messages[-KEEP_RECENT:]
|
||||||
|
else:
|
||||||
|
convo += messages
|
||||||
|
return convo
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_summarize(sid: str, model: str) -> None:
|
||||||
|
"""Compresse les anciens tours dans le résumé (tâche d'arrière-plan)."""
|
||||||
|
try:
|
||||||
|
messages = db.list_messages_for_model(sid)
|
||||||
|
if len(messages) <= SUMMARIZE_AFTER:
|
||||||
|
return
|
||||||
|
|
||||||
|
session = db.get_session(sid) or {}
|
||||||
|
previous = (session.get("summary") or "").strip()
|
||||||
|
old = messages[:-KEEP_RECENT]
|
||||||
|
|
||||||
|
transcript = "\n".join(
|
||||||
|
f"[{m['role']}] {m['content'][:600]}" for m in old
|
||||||
|
)[-8000:]
|
||||||
|
if previous:
|
||||||
|
transcript = f"[résumé existant] {previous}\n{transcript}"
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
async for chunk in ollama.chat(
|
||||||
|
model,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": _SUMMARY_PROMPT},
|
||||||
|
{"role": "user", "content": transcript},
|
||||||
|
],
|
||||||
|
options={"temperature": 0.2, "num_predict": 350},
|
||||||
|
stream=True,
|
||||||
|
):
|
||||||
|
text += chunk.get("message", {}).get("content", "")
|
||||||
|
if chunk.get("done"):
|
||||||
|
break
|
||||||
|
|
||||||
|
if text.strip():
|
||||||
|
db.set_session_summary(sid, text.strip())
|
||||||
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
|
# Best-effort : un échec de résumé ne doit jamais gêner le chat.
|
||||||
|
logger.warning("Résumé de session %s impossible : %s", sid, exc)
|
||||||
@@ -18,7 +18,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import agent_config, coder, db, router as msg_router
|
from .. import agent_config, coder, db, memory, router as msg_router
|
||||||
from ..agent import run_agent
|
from ..agent import run_agent
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
@@ -110,16 +110,23 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
and await msg_router.is_code_task(req.content, model)
|
and await msg_router.is_code_task(req.content, model)
|
||||||
)
|
)
|
||||||
|
|
||||||
convo = [{"role": "system", "content": cfg["system_prompt"]}]
|
# Mémoire compressée : système + résumé des anciens tours + messages récents.
|
||||||
convo += db.list_messages_for_model(req.session_id)
|
convo = memory.build_convo(req.session_id, cfg["system_prompt"])
|
||||||
|
|
||||||
|
# Moteur code : choisit le meilleur modèle code installé (config "auto").
|
||||||
|
code_model = (
|
||||||
|
await coder.pick_code_model(model, cfg.get("code_model"))
|
||||||
|
if use_code else model
|
||||||
|
)
|
||||||
|
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
|
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
|
||||||
|
|
||||||
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
|
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
|
||||||
if use_code:
|
if use_code:
|
||||||
async for chunk in _code_stream(req, model):
|
async for chunk in _code_stream(req, code_model):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
asyncio.create_task(memory.maybe_summarize(req.session_id, model))
|
||||||
return
|
return
|
||||||
|
|
||||||
final_content = ""
|
final_content = ""
|
||||||
@@ -215,6 +222,8 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
"error": error_message or None,
|
"error": error_message or None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# Compression de l'historique en arrière-plan (sans bloquer la réponse).
|
||||||
|
asyncio.create_task(memory.maybe_summarize(req.session_id, model))
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
event_stream(),
|
event_stream(),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ConfigPatch(BaseModel):
|
|||||||
tools: dict[str, bool] | None = None
|
tools: dict[str, bool] | None = None
|
||||||
confirm_shell: bool | None = None
|
confirm_shell: bool | None = None
|
||||||
think: bool | None = None
|
think: bool | None = None
|
||||||
|
code_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|||||||
+168
-2
@@ -50,6 +50,30 @@ def read_file(path: str) -> dict:
|
|||||||
return {"ok": True, "content": content, "summary": summary}
|
return {"ok": True, "content": content, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_written(target: str) -> str | None:
|
||||||
|
"""Vérification syntaxique immédiate après écriture (py/json).
|
||||||
|
|
||||||
|
Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans
|
||||||
|
le même tour, au lieu de livrer un fichier cassé.
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(target)[1].lower()
|
||||||
|
try:
|
||||||
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
|
if ext == ".json":
|
||||||
|
import json as _json
|
||||||
|
_json.loads(content)
|
||||||
|
elif ext == ".py":
|
||||||
|
compile(content, target, "exec")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
return f"SyntaxError ligne {exc.lineno}: {exc.msg}"
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"JSON invalide : {exc}"
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
|
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
|
||||||
target = _safe_path(path)
|
target = _safe_path(path)
|
||||||
if mode not in {"overwrite", "append"}:
|
if mode not in {"overwrite", "append"}:
|
||||||
@@ -60,7 +84,96 @@ def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
lines = len(content.splitlines())
|
lines = len(content.splitlines())
|
||||||
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
|
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
|
||||||
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
result = {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
||||||
|
problem = _verify_written(target)
|
||||||
|
if problem:
|
||||||
|
result["verification"] = problem
|
||||||
|
result["summary"] += f" · ⚠ {problem}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def edit_file(path: str, search: str, replace: str) -> dict:
|
||||||
|
"""Modification chirurgicale : remplace un extrait exact du fichier.
|
||||||
|
|
||||||
|
Bien plus fiable que réécrire tout le fichier avec un petit modèle :
|
||||||
|
seul le fragment visé change, le reste est garanti intact.
|
||||||
|
"""
|
||||||
|
target = _safe_path(path)
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
raise ToolError(f"fichier introuvable : {path}")
|
||||||
|
if not search:
|
||||||
|
raise ToolError("search vide : fournis l'extrait exact à remplacer")
|
||||||
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
count = content.count(search)
|
||||||
|
if count == 0:
|
||||||
|
preview = search.strip().splitlines()[0][:60] if search.strip() else ""
|
||||||
|
raise ToolError(
|
||||||
|
f"extrait introuvable dans {path} (cherché : « {preview}… »). "
|
||||||
|
"Relis le fichier avec read_file et copie l'extrait EXACT."
|
||||||
|
)
|
||||||
|
if count > 1:
|
||||||
|
raise ToolError(
|
||||||
|
f"extrait présent {count} fois dans {path} : ajoute du contexte "
|
||||||
|
"autour pour le rendre unique."
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(target, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content.replace(search, replace, 1))
|
||||||
|
|
||||||
|
delta = len(replace.splitlines()) - len(search.splitlines())
|
||||||
|
result = {"ok": True, "summary": f"remplacé · {delta:+d} ligne(s)"}
|
||||||
|
problem = _verify_written(target)
|
||||||
|
if problem:
|
||||||
|
result["verification"] = problem
|
||||||
|
result["summary"] += f" · ⚠ {problem}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"}
|
||||||
|
_MAX_GREP_MATCHES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def grep_search(pattern: str, path: str = ".") -> dict:
|
||||||
|
"""Recherche un motif (regex) dans les fichiers du workspace."""
|
||||||
|
if not pattern:
|
||||||
|
raise ToolError("pattern vide")
|
||||||
|
try:
|
||||||
|
rx = re.compile(pattern, re.I)
|
||||||
|
except re.error as exc:
|
||||||
|
raise ToolError(f"regex invalide : {exc}")
|
||||||
|
|
||||||
|
root = _safe_path(path)
|
||||||
|
matches: list[str] = []
|
||||||
|
files_hit: set[str] = set()
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in _GREP_SKIP_DIRS]
|
||||||
|
for fname in sorted(filenames):
|
||||||
|
full = os.path.join(dirpath, fname)
|
||||||
|
if os.path.getsize(full) > 1_000_000:
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(full, _workspace_root())
|
||||||
|
try:
|
||||||
|
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
for lineno, line in enumerate(f, 1):
|
||||||
|
if rx.search(line):
|
||||||
|
matches.append(f"{rel}:{lineno}: {line.strip()[:160]}")
|
||||||
|
files_hit.add(rel)
|
||||||
|
if len(matches) >= _MAX_GREP_MATCHES:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if len(matches) >= _MAX_GREP_MATCHES:
|
||||||
|
break
|
||||||
|
if len(matches) >= _MAX_GREP_MATCHES:
|
||||||
|
break
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"{len(matches)} correspondance(s) dans {len(files_hit)} fichier(s)"
|
||||||
|
if matches else "aucune correspondance"
|
||||||
|
)
|
||||||
|
return {"ok": True, "matches": matches, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
def list_dir(path: str = ".") -> dict:
|
def list_dir(path: str = ".") -> dict:
|
||||||
@@ -183,7 +296,9 @@ def run_shell(command: str, timeout: int = 60) -> dict:
|
|||||||
TOOL_IMPL = {
|
TOOL_IMPL = {
|
||||||
"read_file": read_file,
|
"read_file": read_file,
|
||||||
"write_file": write_file,
|
"write_file": write_file,
|
||||||
|
"edit_file": edit_file,
|
||||||
"list_dir": list_dir,
|
"list_dir": list_dir,
|
||||||
|
"grep_search": grep_search,
|
||||||
"web_search": web_search,
|
"web_search": web_search,
|
||||||
"run_shell": run_shell,
|
"run_shell": run_shell,
|
||||||
}
|
}
|
||||||
@@ -228,6 +343,29 @@ TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "edit_file",
|
||||||
|
"description": (
|
||||||
|
"Modifier UN extrait précis d'un fichier existant (recherche/"
|
||||||
|
"remplacement exact). Préférable à write_file pour toute "
|
||||||
|
"modification partielle : le reste du fichier reste intact."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Chemin relatif au workspace"},
|
||||||
|
"search": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Extrait EXACT à remplacer (copie fidèle, unique dans le fichier)",
|
||||||
|
},
|
||||||
|
"replace": {"type": "string", "description": "Nouveau texte"},
|
||||||
|
},
|
||||||
|
"required": ["path", "search", "replace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -241,6 +379,24 @@ TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "grep_search",
|
||||||
|
"description": (
|
||||||
|
"Chercher un motif (regex, insensible à la casse) dans tous les "
|
||||||
|
"fichiers du workspace. Renvoie fichier:ligne:texte."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string", "description": "Motif à chercher"},
|
||||||
|
"path": {"type": "string", "description": "Sous-répertoire (défaut : racine)"},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -314,11 +470,21 @@ def _normalize_args(name: str, args: dict | None) -> dict:
|
|||||||
if alias in args:
|
if alias in args:
|
||||||
args["content"] = args[alias]
|
args["content"] = args[alias]
|
||||||
break
|
break
|
||||||
elif name in {"read_file", "list_dir"} and "path" not in args:
|
elif name in {"read_file", "list_dir", "edit_file"} and "path" not in args:
|
||||||
for alias in ("file_path", "filepath", "filename", "file", "dir", "directory"):
|
for alias in ("file_path", "filepath", "filename", "file", "dir", "directory"):
|
||||||
if args.get(alias):
|
if args.get(alias):
|
||||||
args["path"] = args[alias]
|
args["path"] = args[alias]
|
||||||
break
|
break
|
||||||
|
if name == "edit_file":
|
||||||
|
if "search" not in args and "old" in args:
|
||||||
|
args["search"] = args.pop("old")
|
||||||
|
if "replace" not in args and "new" in args:
|
||||||
|
args["replace"] = args.pop("new")
|
||||||
|
elif name == "grep_search" and "pattern" not in args:
|
||||||
|
for alias in ("query", "search", "text", "regex"):
|
||||||
|
if args.get(alias):
|
||||||
|
args["pattern"] = args[alias]
|
||||||
|
break
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function ToolCard({ call }: { call: ToolCall }) {
|
|||||||
const mainArg =
|
const mainArg =
|
||||||
(call.args?.path as string) ??
|
(call.args?.path as string) ??
|
||||||
(call.args?.query as string) ??
|
(call.args?.query as string) ??
|
||||||
|
(call.args?.pattern as string) ??
|
||||||
(call.args?.command as string) ??
|
(call.args?.command as string) ??
|
||||||
(call.args?.instruction as string);
|
(call.args?.instruction as string);
|
||||||
const argPreview =
|
const argPreview =
|
||||||
@@ -19,7 +20,7 @@ export function ToolCard({ call }: { call: ToolCall }) {
|
|||||||
? "(…)"
|
? "(…)"
|
||||||
: "()";
|
: "()";
|
||||||
|
|
||||||
const write = call.name === "write_file";
|
const write = call.name === "write_file" || call.name === "edit_file";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
|
<div className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
|
||||||
@@ -71,7 +72,7 @@ function ToolGlyph({ name }: { name: string }) {
|
|||||||
strokeLinecap: "round" as const,
|
strokeLinecap: "round" as const,
|
||||||
strokeLinejoin: "round" as const,
|
strokeLinejoin: "round" as const,
|
||||||
};
|
};
|
||||||
if (name === "write_file")
|
if (name === "write_file" || name === "edit_file")
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
|
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
|
||||||
@@ -89,7 +90,7 @@ function ToolGlyph({ name }: { name: string }) {
|
|||||||
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
|
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
if (name === "web_search")
|
if (name === "web_search" || name === "grep_search")
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<circle cx="11" cy="11" r="7" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
|||||||
const TOOL_DESC: Record<string, string> = {
|
const TOOL_DESC: Record<string, string> = {
|
||||||
read_file: "Lire un fichier du projet",
|
read_file: "Lire un fichier du projet",
|
||||||
write_file: "Créer / modifier un fichier",
|
write_file: "Créer / modifier un fichier",
|
||||||
|
edit_file: "Modification chirurgicale (recherche/remplacement)",
|
||||||
list_dir: "Lister un répertoire",
|
list_dir: "Lister un répertoire",
|
||||||
|
grep_search: "Chercher dans les fichiers",
|
||||||
code_task: "Moteur code (multi-fichiers, git)",
|
code_task: "Moteur code (multi-fichiers, git)",
|
||||||
web_search: "Recherche web",
|
web_search: "Recherche web",
|
||||||
run_shell: "Exécuter une commande",
|
run_shell: "Exécuter une commande",
|
||||||
|
|||||||
Reference in new issue
Block a user