Moteur code façon Claude Code : Aider intégré avec routage automatique

- coder.py : enveloppe Aider (version figée 0.86.2), commits git auto dans le
  workspace, verrou d'exécution, dégradation propre si absent
- router.py : classification automatique de chaque message (heuristique
  lexicale + micro-classification LLM sur les cas ambigus) — invisible pour
  l'utilisateur
- routes/chat.py : routage code/agent, chemin code avec keepalive SSE,
  cartes fichier + commit dans le fil, meta.engine persisté
- agent.py : outil code_task exécuté en thread — l'agent peut coder lui-même
- tools/agent_config : code_task enregistré (actif par défaut), invite système
  mise à jour
- main.py : workspace auto-initialisé en dépôt git au démarrage
- Dockerfile : git ; requirements : aider-chat==0.86.2
- UI : glyphe code_task, description outil, aperçu d'instruction

Tests : heuristiques 7/7, routage HTTP code+agent de bout en bout (SSE,
meta persistée), agent appelant code_task, 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:
Claude committed 2026-07-02 05:27:58 +00:00
1 parent ecadf55743
commit ac801aa71b
12 files changed
+356 -15

No files matched your search

+2 -2
View File
@@ -16,8 +16,8 @@ ENV PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
PORT=8717
# curl pour le HEALTHCHECK
RUN apt-get update && apt-get install -y --no-install-recommends curl \
# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider)
RUN apt-get update && apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./
+18
View File
@@ -115,6 +115,24 @@ npm run dev # http://localhost:5173
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
## Moteur code (façon Claude Code) — routage automatique
Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme
**moteur code** : édition multi-fichiers fiable (formats diff/search-replace,
efficaces même avec de petits modèles), repo map, et **commits git
automatiques** dans le workspace.
**C'est invisible** : un routeur classe chaque message.
- Tâche de code détectée (heuristique + micro-classification LLM sur les cas
ambigus) → le message part au **moteur code** ; le fil affiche la carte
`code_task`, les fichiers modifiés et le commit.
- Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au
moteur via l'outil `code_task` quand il juge qu'il faut coder.
Désactivable dans Configuration → Outils (`code_task`). Le workspace est
auto-initialisé en dépôt git : chaque modification de code = un commit
(historique et rollback via `git log` / `git revert` dans le workspace).
## Profil GPU fourni
Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 :
+26
View File
@@ -13,11 +13,13 @@ relayés tels quels au client via SSE.
"""
from __future__ import annotations
import asyncio
import json
from typing import AsyncIterator
import httpx
from . import coder
from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
@@ -260,6 +262,30 @@ async def run_agent(
awaiting_confirmation = True
continue
# code_task : délégué au moteur code (Aider), long -> thread.
if name == "code_task":
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
model,
args.get("files") or [],
)
summary = result.get("summary", "terminé")
status = "ok" if result.get("ok") else "error"
record = {"name": name, "args": {"instruction": args.get("instruction", "")},
"summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{k: result.get(k) for k in ("ok", "summary", "files", "text")},
ensure_ascii=False,
),
})
continue
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
+12 -7
View File
@@ -14,12 +14,14 @@ PROFILE_VERSION = 5
DEFAULT_SYSTEM_PROMPT = (
"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. Quand tu appelles write_file, fournis toujours "
"`path` et `content`. Pour un fichier long, appelle write_file en plusieurs "
"morceaux (overwrite puis append) afin de toujours produire un JSON valide. "
"Après avoir écrit un fichier, propose un aperçu. "
"d'outils pour lire, écrire et lister des fichiers dans le workspace, et "
"d'un moteur code (code_task) pour toute création ou modification de code "
"multi-fichiers : privilégie code_task pour les tâches de programmation. "
"Utilise les outils pour accomplir les tâches concrètement, puis réponds "
"de façon concise en français. Quand tu appelles write_file, fournis "
"toujours `path` et `content`. Pour un fichier long, appelle write_file en "
"plusieurs morceaux (overwrite puis append) afin de toujours produire un "
"JSON valide. Après avoir écrit un fichier, propose un aperçu. "
"Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les "
"points clés, tableaux si pertinent, et surtout des blocs de code avec le "
"langage indiqué (```python, ```html…) pour tout extrait de code ou commande."
@@ -27,12 +29,15 @@ DEFAULT_SYSTEM_PROMPT = (
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
# par défaut, conformément à la maquette.
AVAILABLE_TOOLS = ["read_file", "write_file", "list_dir", "web_search", "run_shell"]
AVAILABLE_TOOLS = [
"read_file", "write_file", "list_dir", "code_task", "web_search", "run_shell",
]
SENSITIVE_TOOLS = {"run_shell"}
DEFAULT_TOOL_STATE = {
"read_file": True,
"write_file": True,
"list_dir": True,
"code_task": True,
"web_search": False,
"run_shell": False,
}
+106
View File
@@ -0,0 +1,106 @@
"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable.
Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition
diff/search-replace robustes (même avec de petits modèles), repo map, et
commits git automatiques dans le workspace.
L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE
dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la
fonction (l'app démarre même si Aider est absent).
"""
from __future__ import annotations
import os
import subprocess
import threading
from .config import settings
# Aider utilise le répertoire courant : un seul run à la fois.
_RUN_LOCK = threading.Lock()
def ensure_git(root: str) -> None:
"""Initialise un dépôt git dans le workspace (requis pour les commits Aider)."""
os.makedirs(root, exist_ok=True)
if os.path.isdir(os.path.join(root, ".git")):
return
subprocess.run(["git", "init", "-q"], cwd=root, check=False)
subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False)
subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False)
def available() -> bool:
"""Aider est-il installé ?"""
try:
import aider # noqa: F401
return True
except ImportError:
return False
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
) -> dict:
"""Exécute une tâche de code via Aider (synchrone — lancer dans un thread).
Renvoie {ok, text, files, commit, commit_message, summary}.
"""
if not available():
return {
"ok": False,
"summary": "moteur code indisponible (aider non installé)",
"text": "", "files": [], "commit": None,
}
from aider.coders import Coder
from aider.io import InputOutput
from aider.models import Model
root = os.path.abspath(settings.workspace_dir)
ensure_git(root)
# Aider parle à Ollama via litellm : on pointe vers notre instance.
os.environ["OLLAMA_API_BASE"] = settings.ollama_host
with _RUN_LOCK:
prev_cwd = os.getcwd()
os.chdir(root)
try:
io = InputOutput(yes=True, pretty=False, fancy_input=False)
coder = Coder.create(
main_model=Model(f"ollama_chat/{model}"),
io=io,
fnames=[os.path.join(root, f) for f in (files or [])],
auto_commits=True,
stream=False,
use_git=True,
suggest_shell_commands=False,
detect_urls=False,
)
text = coder.run(instruction) or ""
edited = sorted(coder.aider_edited_files or [])
commit = getattr(coder, "last_aider_commit_hash", None)
commit_msg = getattr(coder, "last_aider_commit_message", None)
except Exception as exc: # aider peut lever des erreurs variées
return {
"ok": False,
"summary": f"échec moteur code : {exc}",
"text": "", "files": [], "commit": None,
}
finally:
os.chdir(prev_cwd)
summary = f"{len(edited)} fichier(s) modifié(s)"
if commit:
summary += f" · commit {str(commit)[:7]}"
return {
"ok": True,
"text": text.strip(),
"files": edited,
"commit": commit,
"commit_message": commit_msg,
"summary": summary,
}
+3 -1
View File
@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import db
from . import coder, db
from .config import settings
from .routes import chat, config, files, models, sessions, shell, system
@@ -20,6 +20,8 @@ from .routes import chat, config, files, models, sessions, shell, system
@asynccontextmanager
async def lifespan(_: FastAPI):
db.init_db()
# Workspace en dépôt git : requis pour les commits du moteur code (Aider).
coder.ensure_git(settings.workspace_dir)
yield
+81
View File
@@ -0,0 +1,81 @@
"""Routeur automatique : moteur code (Aider) ou boucle agent classique ?
Invisible pour l'utilisateur : chaque message est classé.
1. Heuristique lexicale rapide (gratuite) — tranche les cas évidents.
2. Cas ambigus : micro-classification par le modèle lui-même (3 jetons).
"""
from __future__ import annotations
import re
import httpx
from .ollama_client import ollama
# Vocabulaire fortement lié au code / au développement.
_STRONG = re.compile(
r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|"
r"javascript|js|python|typescript|react|api|composant|page|site|landing|"
r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b",
re.I,
)
# Extensions de fichiers mentionnées explicitement.
_FILE_EXT = re.compile(
r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I
)
# Verbes d'action de création/modification.
_ACTION = re.compile(
r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|"
r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b",
re.I,
)
def score_code_task(message: str) -> int:
"""Score heuristique : >= 3 -> code, <= 1 -> agent, 2 -> ambigu."""
score = 0
if _STRONG.search(message):
score += 2
if _FILE_EXT.search(message):
score += 2
if _ACTION.search(message):
score += 1
if "```" in message:
score += 2
return score
async def is_code_task(message: str, model: str) -> bool:
"""Décision finale : heuristique, puis LLM sur les cas ambigus."""
score = score_code_task(message)
if score >= 3:
return True
if score <= 1:
return False
# Ambigu : on demande au modèle (réponse d'un mot, quasi instantané).
try:
answer = ""
async for chunk in ollama.chat(
model,
[
{
"role": "system",
"content": (
"Tu classes des demandes. Réponds UNIQUEMENT par CODE "
"si la demande nécessite d'écrire ou modifier du code "
"ou des fichiers de projet, sinon CHAT."
),
},
{"role": "user", "content": message[:500]},
],
options={"num_predict": 4, "temperature": 0},
stream=True,
):
answer += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
return "CODE" in answer.upper()
except (httpx.HTTPError, OSError):
# Ollama indisponible : la boucle agent gérera l'erreur proprement.
return False
+70 -4
View File
@@ -1,8 +1,9 @@
"""Route de conversation agentique : boucle d'outils + streaming SSE.
"""Route de conversation : routage automatique agent / moteur code + SSE.
Flux :
1. on enregistre le message utilisateur ;
2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
2. le routeur classe la demande : tâche de code -> moteur code (Aider),
sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ;
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.
"""
@@ -17,7 +18,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import agent_config, db
from .. import agent_config, coder, db, router as msg_router
from ..agent import run_agent
from ..config import settings
@@ -35,6 +36,56 @@ def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _code_stream(req: ChatRequest, model: str):
"""Chemin « moteur code » : Aider travaille, on garde le flux SSE vivant."""
instruction = req.content
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": instruction}})
task = asyncio.create_task(
asyncio.to_thread(coder.run_code_task, instruction, model, None)
)
# Keepalive SSE pendant le travail (peut durer plusieurs minutes).
while not task.done():
await asyncio.sleep(10)
if not task.done():
yield ": keepalive\n\n"
result = await task
status = "ok" if result.get("ok") else "error"
record = {
"name": "code_task",
"args": {"instruction": instruction},
"summary": result.get("summary", "terminé"),
"status": status,
}
tools_meta = [record]
yield _sse("tool_result", record)
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
for f in result.get("files") or []:
file_rec = {
"name": "write_file",
"args": {"path": f},
"summary": "modifié par le moteur code",
"status": "ok",
}
tools_meta.append(file_rec)
yield _sse("tool_call", {"name": "write_file", "args": {"path": f}})
yield _sse("tool_result", file_rec)
text = result.get("text") or (
"" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}"
)
if text:
yield _sse("token", {"content": text})
db.add_message(
req.session_id, "assistant", text, model,
meta={"tools": tools_meta, "engine": "code"},
)
yield _sse("done", {"content": text, "tools": tools_meta})
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
@@ -51,11 +102,26 @@ async def chat(req: ChatRequest) -> StreamingResponse:
db.add_message(req.session_id, "user", req.content, None)
# Routage automatique : moteur code si la demande est une tâche de code,
# que l'outil est actif et qu'Aider est disponible.
use_code = (
cfg["tools"].get("code_task", True)
and coder.available()
and await msg_router.is_code_task(req.content, model)
)
convo = [{"role": "system", "content": cfg["system_prompt"]}]
convo += db.list_messages_for_model(req.session_id)
async def event_stream():
yield _sse("start", {"model": model})
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.
if use_code:
async for chunk in _code_stream(req, model):
yield chunk
return
final_content = ""
tools_meta: list[dict] = []
stats_meta: dict | None = None
+26
View File
@@ -241,6 +241,32 @@ TOOL_DEFINITIONS = [
},
},
},
{
"type": "function",
"function": {
"name": "code_task",
"description": (
"Confier une tâche de programmation au moteur code (édition "
"multi-fichiers fiable, commits git). À utiliser pour créer ou "
"modifier du code."
),
"parameters": {
"type": "object",
"properties": {
"instruction": {
"type": "string",
"description": "La tâche de code, précise et complète",
},
"files": {
"type": "array",
"items": {"type": "string"},
"description": "Fichiers concernés (optionnel)",
},
},
"required": ["instruction"],
},
},
},
{
"type": "function",
"function": {
+3
View File
@@ -4,3 +4,6 @@ httpx==0.28.1
pydantic==2.10.4
pydantic-settings==2.7.1
psutil==6.1.1
# Moteur code (édition multi-fichiers façon Claude Code). Version FIGÉE :
# l'API Python d'Aider n'est pas garantie stable entre versions.
aider-chat==0.86.2
+8 -1
View File
@@ -10,7 +10,8 @@ export function ToolCard({ call }: { call: ToolCall }) {
const mainArg =
(call.args?.path as string) ??
(call.args?.query as string) ??
(call.args?.command as string);
(call.args?.command as string) ??
(call.args?.instruction as string);
const argPreview =
typeof mainArg === "string"
? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")`
@@ -82,6 +83,12 @@ function ToolGlyph({ name }: { name: string }) {
<path d="M4 7h6l2 2h8v9H4z" />
</svg>
);
if (name === "code_task")
return (
<svg {...common}>
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
</svg>
);
if (name === "web_search")
return (
<svg {...common}>
+1
View File
@@ -8,6 +8,7 @@ const TOOL_DESC: Record<string, string> = {
read_file: "Lire un fichier du projet",
write_file: "Créer / modifier un fichier",
list_dir: "Lister un répertoire",
code_task: "Moteur code (multi-fichiers, git)",
web_search: "Recherche web",
run_shell: "Exécuter une commande",
};