mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
feat(agent): mémoire de travail code — récap session + état workspace
« Corrige les bugs » partait à Aider sans contexte (quel fichier ? quel projet ?) et le chemin agent ignorait quels fichiers existaient — le modèle collait alors le code en chat au lieu de modifier le fichier. - moteur code : récap de session (demande initiale + dernières demandes + fichiers touchés) préfixé à l'instruction ; fichiers de la session passés à Aider quand aucun n'est cité - chemin agent : liste des fichiers du workspace + fichiers de la tâche injectée chaque tour en système - directive renforcée : « ne colle JAMAIS le code corrigé dans ta réponse sans l'avoir écrit dans le fichier » Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
584d28a9d0
commit
bddab1c619
2 files changed
+122
-5
No files matched your search
@@ -79,6 +79,63 @@ def _prev_was_code(history: list[dict]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
|
||||
"""Récap compact du travail en cours + fichiers touchés dans la session.
|
||||
|
||||
Le moteur code ne reçoit que le message courant : sur une reprise
|
||||
(« corrige les bugs »), sans ce récap il ignore quel fichier, quel projet
|
||||
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
|
||||
par défaut pour Aider.
|
||||
"""
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
files: list[str] = []
|
||||
for m in history:
|
||||
if m["role"] != "assistant":
|
||||
continue
|
||||
for t in (m.get("meta") or {}).get("tools") or []:
|
||||
candidates: list[str] = []
|
||||
path = (t.get("args") or {}).get("path")
|
||||
if t.get("name") in ("write_file", "edit_file") and path:
|
||||
candidates.append(str(path))
|
||||
for f in t.get("files") or []:
|
||||
candidates.append(str(f))
|
||||
for c in candidates:
|
||||
rel = c.replace("\\", "/").lstrip("./")
|
||||
if rel not in files and os.path.isfile(os.path.join(root, rel)):
|
||||
files.append(rel)
|
||||
|
||||
user_msgs = [m["content"].strip() for m in history if m["role"] == "user"]
|
||||
lines: list[str] = []
|
||||
if user_msgs:
|
||||
lines.append(f"- Demande initiale : {user_msgs[0][:200]}")
|
||||
for prev in user_msgs[-2:]:
|
||||
if prev != user_msgs[0]:
|
||||
lines.append(f"- Puis : {prev[:200]}")
|
||||
if files:
|
||||
lines.append(f"- Fichiers déjà créés/modifiés : {', '.join(files[:8])}")
|
||||
recap = (
|
||||
"Contexte de la session (travail en cours) :\n" + "\n".join(lines)
|
||||
if lines else ""
|
||||
)
|
||||
return recap, files
|
||||
|
||||
|
||||
def _workspace_listing(limit: int = 40) -> list[str]:
|
||||
"""Chemins relatifs des fichiers du workspace (aperçu compact)."""
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
out: list[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||
for name in sorted(filenames):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, name), root)
|
||||
out.append(rel.replace("\\", "/"))
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
_FILE_MENTION = re.compile(r"[\w][\w./\\-]*\.[a-z0-9]{1,5}\b", re.I)
|
||||
|
||||
|
||||
@@ -288,6 +345,19 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
+ "\n---\n".join(memories),
|
||||
})
|
||||
|
||||
# État du workspace injecté chaque tour : sans ça le modèle ignore
|
||||
# quels fichiers existent et régurgite du code en chat au lieu de
|
||||
# modifier le bon fichier.
|
||||
listing = _workspace_listing()
|
||||
if listing:
|
||||
recap, session_files = _session_code_context(history)
|
||||
parts = ["Fichiers du workspace : " + ", ".join(listing)]
|
||||
if session_files:
|
||||
parts.append(
|
||||
"Fichiers de la tâche en cours : " + ", ".join(session_files[:8])
|
||||
)
|
||||
convo.insert(1, {"role": "system", "content": "\n".join(parts)})
|
||||
|
||||
# Session code restée en chemin agent : pousse le modèle à AGIR sur
|
||||
# les fichiers au lieu de décrire les changements — cause fréquente de
|
||||
# « l'agent s'arrête sans rien modifier » sur une reprise de code.
|
||||
@@ -300,8 +370,9 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
"les outils — code_task pour un changement multi-fichiers, "
|
||||
"edit_file pour un changement ciblé, write_file pour un "
|
||||
"nouveau fichier. Lis le fichier concerné avant de le "
|
||||
"modifier. Ne réponds JAMAIS par une simple description "
|
||||
"des changements sans les appliquer."
|
||||
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
|
||||
"code corrigé dans ta réponse sans l'avoir écrit dans le "
|
||||
"fichier."
|
||||
),
|
||||
})
|
||||
|
||||
@@ -327,9 +398,16 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
|
||||
if plan else ""
|
||||
)
|
||||
# Reprise : Aider ne voit que le message courant — on lui donne le
|
||||
# récap de session et, à défaut de fichiers cités, ceux déjà
|
||||
# touchés (« corrige les bugs » => il ouvre le bon fichier).
|
||||
recap, session_files = _session_code_context(history)
|
||||
extra = instruction_plan
|
||||
if recap:
|
||||
extra = f"\n\n{recap}" + extra
|
||||
code_files = _mentioned_files(req.content) or session_files[:8]
|
||||
async for chunk in _code_stream(
|
||||
req, code_model, extra=instruction_plan, plan=plan,
|
||||
files=_mentioned_files(req.content),
|
||||
req, code_model, extra=extra, plan=plan, files=code_files,
|
||||
):
|
||||
yield chunk
|
||||
asyncio.create_task(memory.maybe_summarize(
|
||||
|
||||
@@ -5,7 +5,12 @@ os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
|
||||
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
|
||||
|
||||
from app import router # noqa: E402
|
||||
from app.routes.chat import _mentioned_files, _prev_was_code # noqa: E402
|
||||
from app.routes.chat import ( # noqa: E402
|
||||
_mentioned_files,
|
||||
_prev_was_code,
|
||||
_session_code_context,
|
||||
_workspace_listing,
|
||||
)
|
||||
from app.config import settings # noqa: E402
|
||||
|
||||
|
||||
@@ -55,3 +60,37 @@ def test_mentioned_files_existants_seulement():
|
||||
f.write("<html></html>")
|
||||
found = _mentioned_files("modifie index.html et style.css")
|
||||
assert found == ["index.html"] # style.css n'existe pas
|
||||
|
||||
|
||||
def test_session_code_context_recap_et_fichiers():
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
|
||||
f.write("<html>jeu</html>")
|
||||
history = [
|
||||
{"role": "user", "content": "crée un jeu snake dans jeu.html",
|
||||
"meta": None},
|
||||
{"role": "assistant", "content": "fait",
|
||||
"meta": {"engine": "code",
|
||||
"tools": [{"name": "write_file",
|
||||
"args": {"path": "jeu.html"}}]}},
|
||||
{"role": "user", "content": "il y a des bugs", "meta": None},
|
||||
]
|
||||
recap, files = _session_code_context(history)
|
||||
assert "crée un jeu snake" in recap
|
||||
assert files == ["jeu.html"]
|
||||
|
||||
|
||||
def test_session_code_context_vide_sans_historique():
|
||||
recap, files = _session_code_context([])
|
||||
assert recap == ""
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_workspace_listing_contient_fichiers():
|
||||
root = os.path.abspath(settings.workspace_dir)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
|
||||
f.write("x")
|
||||
listing = _workspace_listing()
|
||||
assert "jeu.html" in listing
|
||||
Reference in new issue
Block a user