feat(agent): run_check statique + vérification auto après écriture

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-18 17:03:58 +02:00
1 parent 31dce6036b
commit c92e4d4a13
5 files changed
+141 -2

No files matched your search

+30
View File
@@ -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).
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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:
+65
View File
@@ -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"],
},
},
},
]
+43
View File
@@ -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