From c92e4d4a13a8c58092a4c5090d77d5307f534737 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 18 Jul 2026 17:03:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20run=5Fcheck=20statique=20+=20v?= =?UTF-8?q?=C3=A9rification=20auto=20apr=C3=A8s=20=C3=A9criture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/app/agent.py | 30 +++++++++++++++ backend/app/agent_config.py | 3 +- backend/app/routes/chat.py | 2 +- backend/app/tools.py | 65 +++++++++++++++++++++++++++++++++ backend/tests/test_run_check.py | 43 ++++++++++++++++++++++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_run_check.py diff --git a/backend/app/agent.py b/backend/app/agent.py index 4f8428a..edaaf0a 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -334,6 +334,36 @@ async def run_agent( } ) + # Vérification statique automatique après toute écriture de + # code : l'erreur revient au modèle dans le même tour + # (1 passe de correction max, bornée par MAX_ITERATIONS). + if ( + name in ("write_file", "edit_file") + and status == "ok" + and str(args.get("path", "")).lower().endswith( + (".py", ".js", ".mjs", ".html", ".htm", ".json") + ) + ): + try: + from .tools import run_check + check = run_check(args["path"]) + except ToolError: + check = None + if check and not check["ok"]: + record = { + "name": "run_check", + "args": {"path": args["path"]}, + "summary": check["summary"], + "status": "error", + } + collected.append(record) + yield {"type": "tool_result", **record} + convo.append({ + "role": "tool", + "tool_name": "run_check", + "content": json.dumps(check, ensure_ascii=False), + }) + # Une commande shell attend une validation : on interrompt la boucle. if awaiting_confirmation: # Laisse le modèle conclure son tour (message d'attente). diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index 6fe4410..e5c5127 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -31,7 +31,7 @@ DEFAULT_SYSTEM_PROMPT = ( # par défaut, conformément à la maquette. AVAILABLE_TOOLS = [ "read_file", "write_file", "edit_file", "list_dir", "grep_search", - "code_task", "web_search", "run_shell", + "run_check", "code_task", "web_search", "run_shell", ] SENSITIVE_TOOLS = {"run_shell"} DEFAULT_TOOL_STATE = { @@ -40,6 +40,7 @@ DEFAULT_TOOL_STATE = { "edit_file": True, "list_dir": True, "grep_search": True, + "run_check": True, "code_task": True, "web_search": False, "run_shell": False, diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 2717c14..10480cd 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -37,7 +37,7 @@ class ChatRequest(BaseModel): # Outils autorisés en mode Plan : lecture/analyse uniquement. -_READONLY_TOOLS = {"read_file", "list_dir", "grep_search"} +_READONLY_TOOLS = {"read_file", "list_dir", "grep_search", "run_check"} def _apply_mode(cfg: dict, mode: str) -> dict: diff --git a/backend/app/tools.py b/backend/app/tools.py index afa68c6..f6cf731 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -10,8 +10,10 @@ toute tentative de sortie (../, chemin absolu hors workspace) est rejetée. from __future__ import annotations import html +import json import os import re +import shutil import subprocess import httpx @@ -328,6 +330,51 @@ def run_shell(command: str, timeout: int = 60) -> dict: } +def run_check(path: str) -> dict: + """Vérification STATIQUE d'un fichier de code — n'exécute jamais rien. + + .py -> py_compile ; .js/.mjs -> node --check (si node présent) ; + .html -> check_html ; .json -> parse. Autres types : ok sans contrôle. + """ + target = _safe_path(path) + if not os.path.isfile(target): + raise ToolError(f"fichier introuvable : {path}") + ext = os.path.splitext(target)[1].lower() + issues: list[str] = [] + + if ext == ".py": + import py_compile + try: + py_compile.compile(target, doraise=True) + except py_compile.PyCompileError as exc: + issues.append(str(exc.msg)[:500]) + elif ext in (".js", ".mjs"): + node = shutil.which("node") + if node: + proc = subprocess.run( + [node, "--check", target], capture_output=True, text=True, + timeout=15, + ) + if proc.returncode != 0: + issues.append((proc.stderr or proc.stdout)[:500]) + elif ext in (".html", ".htm"): + issues.extend(check_html(target)) + elif ext == ".json": + try: + with open(target, encoding="utf-8") as f: + json.load(f) + except json.JSONDecodeError as exc: + issues.append(f"JSON invalide : {exc}") + + ok = not issues + return { + "ok": ok, + "issues": issues, + "summary": "aucun problème" if ok else f"{len(issues)} problème(s)", + "_status": "ok" if ok else "error", + } + + # ── Registre & définitions exposées au modèle ──────────────────────────── TOOL_IMPL = { "read_file": read_file, @@ -337,6 +384,7 @@ TOOL_IMPL = { "grep_search": grep_search, "web_search": web_search, "run_shell": run_shell, + "run_check": run_check, } TOOL_DEFINITIONS = [ @@ -490,6 +538,23 @@ TOOL_DEFINITIONS = [ }, }, }, + { + "type": "function", + "function": { + "name": "run_check", + "description": ( + "Vérifier statiquement un fichier de code du workspace " + "(syntaxe Python/JS/JSON, structure HTML). N'exécute rien." + ), + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Chemin relatif au workspace"} + }, + "required": ["path"], + }, + }, + }, ] diff --git a/backend/tests/test_run_check.py b/backend/tests/test_run_check.py new file mode 100644 index 0000000..43087a2 --- /dev/null +++ b/backend/tests/test_run_check.py @@ -0,0 +1,43 @@ +import os +import tempfile + +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) +os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp()) + +from app import tools # noqa: E402 +from app.config import settings # noqa: E402 + +# Le workspace effectif peut avoir été fixé par un autre fichier de test +# importé avant celui-ci : on écrit là où les outils lisent réellement. +_WS = os.path.abspath(settings.workspace_dir) +os.makedirs(_WS, exist_ok=True) + + +def _write(name: str, content: str) -> str: + path = os.path.join(_WS, name) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return name + + +def test_python_valide(): + rel = _write("ok.py", "x = 1\nprint(x)\n") + assert tools.run_check(rel)["ok"] is True + + +def test_python_casse(): + rel = _write("ko.py", "def broken(:\n") + result = tools.run_check(rel) + assert result["ok"] is False + assert result["issues"] + + +def test_json_casse(): + rel = _write("ko.json", "{invalid") + assert tools.run_check(rel)["ok"] is False + + +def test_fichier_inconnu_type(): + rel = _write("notes.txt", "bonjour") + result = tools.run_check(rel) + assert result["ok"] is True # type non vérifiable = pas d'erreur