From 889de8c052a1bb84f5f79f96756fdbc830af964e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 09:31:20 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20400=20Ollama=20:=20un=20seul=20message=20?= =?UTF-8?q?syst=C3=A8me,=20en=20premi=C3=A8re=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beaucoup de templates (Gemma, Mistral…) contiennent un garde « {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » : tout message système qui n'est pas le premier fait échouer la requête ENTIÈRE en 400, y compris la génération du parseur d'outils — d'où « Unable to generate parser for this template […] System message must be at the beginning », même sur un simple « bonjour ». Loki en empilait jusqu'à huit : souvenirs, état du workspace, reprise de code, skill, Ponytail, contraintes web, notes de mémoire, et surtout le plan, ajouté APRÈS les messages utilisateur. - Les consignes du tour sont désormais collectées puis fusionnées par _merge_system dans l'UNIQUE message système en tête, qui absorbe aussi les systèmes égarés. - memory.build_convo fusionne le résumé de session dans l'invite système au lieu d'ajouter un second message système : correct même seul. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N --- backend/app/memory.py | 21 ++++---- backend/app/routes/chat.py | 108 +++++++++++++++++++++++-------------- 2 files changed, 79 insertions(+), 50 deletions(-) diff --git a/backend/app/memory.py b/backend/app/memory.py index ae01e96..8daa08c 100644 --- a/backend/app/memory.py +++ b/backend/app/memory.py @@ -32,24 +32,23 @@ _SUMMARY_PROMPT = ( 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}] + """Construit le contexte : système + résumé éventuel + messages récents. + Le résumé est fusionné DANS l'invite système plutôt qu'ajouté comme second + message système : de nombreux templates (Gemma, Mistral…) rejettent tout + message système qui n'est pas le premier de la liste. + """ 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}", - } + system_prompt = ( + f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}" ) - convo += messages[-KEEP_RECENT:] - else: - convo += messages - return convo + messages = messages[-KEEP_RECENT:] + + return [{"role": "system", "content": system_prompt}, *messages] async def maybe_summarize( diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 00fbfe4..2d5e8a0 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -61,6 +61,36 @@ def _apply_mode(cfg: dict, mode: str) -> dict: return cfg +def _merge_system(convo: list[dict], extras: list[str]) -> None: + """Garantit UN SEUL message système, en première position. + + Beaucoup de templates Ollama (Gemma, Mistral…) contiennent un garde + « {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » : + tout message système qui n'est pas le premier fait échouer la requête + entière en 400 (« System message must be at the beginning »), y compris + la génération du parseur d'outils. + + On fusionne donc, dans l'ordre : l'invite système, les éventuels systèmes + égarés (résumé de session…) puis les consignes du tour. Modifie ``convo`` + sur place. + """ + head = "" + stray: list[str] = [] + rest: list[dict] = [] + for i, msg in enumerate(convo): + if msg.get("role") == "system": + text = (msg.get("content") or "").strip() + if i == 0: + head = text + elif text: + stray.append(text) + else: + rest.append(msg) + + blocks = [b for b in (head, *stray, *(e.strip() for e in extras if e)) if b] + convo[:] = ([{"role": "system", "content": "\n\n".join(blocks)}] if blocks else []) + rest + + def _sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" @@ -377,12 +407,19 @@ async def chat(req: ChatRequest) -> StreamingResponse: for mcp_notice in mcp_manager.notices(): yield _sse("notice", {"message": mcp_notice}) + # ── Consignes additionnelles du tour ────────────────────────────── + # Elles sont COLLECTÉES ici puis fusionnées dans l'UNIQUE message + # système (voir _merge_system plus bas). Beaucoup de templates + # (Gemma, Mistral…) lèvent « System message must be at the beginning » + # dès qu'un second message system apparaît : les empiler faisait + # échouer toute la requête en 400. + extras: list[str] = [] + if memories: - convo.insert(1, { - "role": "system", - "content": "Souvenirs pertinents d'anciennes sessions :\n" - + "\n---\n".join(memories), - }) + extras.append( + "Souvenirs pertinents d'anciennes sessions :\n" + + "\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 @@ -395,49 +432,41 @@ async def chat(req: ChatRequest) -> StreamingResponse: parts.append( "Fichiers de la tâche en cours : " + ", ".join(session_files[:8]) ) - convo.insert(1, {"role": "system", "content": "\n".join(parts)}) + extras.append("\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. if prev_code and not use_code: - convo.insert(1, { - "role": "system", - "content": ( - "Cette session travaille sur du code existant du workspace. " - "Pour toute demande de modification ou d'ajout : AGIS avec " - "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, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le " - "code corrigé dans ta réponse sans l'avoir écrit dans le " - "fichier." - ), - }) + extras.append( + "Cette session travaille sur du code existant du workspace. " + "Pour toute demande de modification ou d'ajout : AGIS avec " + "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, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le " + "code corrigé dans ta réponse sans l'avoir écrit dans le " + "fichier." + ) # Skill : méthode experte injectée pour ce tour (jamais persistée). if cfg.get("skills_enabled", True): skill = skills.pick_skill(req.content) if skill: - convo.insert(1, { - "role": "system", - "content": ( - "Méthode à suivre pour cette tâche :\n" + skill["body"] - ), - }) + extras.append("Méthode à suivre pour cette tâche :\n" + skill["body"]) yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"}) # Ponytail : méthode « code minimal » injectée pour toute tâche de code # (les deux chemins). Contre la sur-ingénierie qui casse les rendus. is_code_like = use_code or msg_router.is_code_task(req.content) if cfg.get("ponytail", True) and is_code_like: - convo.insert(1, {"role": "system", "content": skills.PONYTAIL_GUIDANCE}) + extras.append(skills.PONYTAIL_GUIDANCE) # Appli web : contraintes dures (fichier autonome, zéro dépendance # externe, rendu réel) pour que ça marche vraiment hors-ligne. want_web = is_code_like and skills.is_web_task(req.content) if want_web: - convo.insert(1, {"role": "system", "content": skills.WEBAPP_GUIDANCE}) + extras.append(skills.WEBAPP_GUIDANCE) # Mémoire en notes : en mode « always », on injecte les notes liées à la # demande. Rien n'est deviné — ce sont des notes que l'agent a @@ -455,7 +484,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: if memory_mode == "always": block = memory_notes.recall_block(req.content) if block: - convo.insert(1, {"role": "system", "content": block}) + extras.append(block) if plan: yield _sse("plan", {"steps": plan}) @@ -498,17 +527,18 @@ async def chat(req: ChatRequest) -> StreamingResponse: return if plan: - convo.append({ - "role": "system", - "content": ( - "Plan à suivre pour cette demande, étape par étape :\n" - + "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)) - + "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est " - "réellement accomplie, écris sur une ligne seule " - "« ✅ Étape N terminée » (N = son numéro) avant de passer à " - "la suivante. N'annonce jamais une étape terminée à l'avance." - ), - }) + extras.append( + "Plan à suivre pour cette demande, étape par étape :\n" + + "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)) + + "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est " + "réellement accomplie, écris sur une ligne seule " + "« ✅ Étape N terminée » (N = son numéro) avant de passer à " + "la suivante. N'annonce jamais une étape terminée à l'avance." + ) + + # Fusion : un SEUL message système, en tête. Indispensable pour les + # templates qui refusent tout system ailleurs qu'en première position. + _merge_system(convo, extras) final_content = "" tools_meta: list[dict] = []