mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Fix 400 Ollama : un seul message système, en première position
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
2 files changed
+79
-50
No files matched your search
+10
-11
@@ -32,24 +32,23 @@ _SUMMARY_PROMPT = (
|
|||||||
|
|
||||||
|
|
||||||
def build_convo(sid: str, system_prompt: str) -> list[dict]:
|
def build_convo(sid: str, system_prompt: str) -> list[dict]:
|
||||||
"""Construit le contexte : système + résumé éventuel + messages récents."""
|
"""Construit le contexte : système + résumé éventuel + messages récents.
|
||||||
convo: list[dict] = [{"role": "system", "content": system_prompt}]
|
|
||||||
|
|
||||||
|
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 {}
|
session = db.get_session(sid) or {}
|
||||||
summary = (session.get("summary") or "").strip()
|
summary = (session.get("summary") or "").strip()
|
||||||
messages = db.list_messages_for_model(sid)
|
messages = db.list_messages_for_model(sid)
|
||||||
|
|
||||||
if summary and len(messages) > KEEP_RECENT:
|
if summary and len(messages) > KEEP_RECENT:
|
||||||
convo.append(
|
system_prompt = (
|
||||||
{
|
f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}"
|
||||||
"role": "system",
|
|
||||||
"content": f"Résumé des échanges précédents :\n{summary}",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
convo += messages[-KEEP_RECENT:]
|
messages = messages[-KEEP_RECENT:]
|
||||||
else:
|
|
||||||
convo += messages
|
return [{"role": "system", "content": system_prompt}, *messages]
|
||||||
return convo
|
|
||||||
|
|
||||||
|
|
||||||
async def maybe_summarize(
|
async def maybe_summarize(
|
||||||
|
|||||||
+69
-39
@@ -61,6 +61,36 @@ def _apply_mode(cfg: dict, mode: str) -> dict:
|
|||||||
return cfg
|
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:
|
def _sse(event: str, data: dict) -> str:
|
||||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
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():
|
for mcp_notice in mcp_manager.notices():
|
||||||
yield _sse("notice", {"message": mcp_notice})
|
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:
|
if memories:
|
||||||
convo.insert(1, {
|
extras.append(
|
||||||
"role": "system",
|
"Souvenirs pertinents d'anciennes sessions :\n"
|
||||||
"content": "Souvenirs pertinents d'anciennes sessions :\n"
|
+ "\n---\n".join(memories)
|
||||||
+ "\n---\n".join(memories),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# État du workspace injecté chaque tour : sans ça le modèle ignore
|
# É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
|
# 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(
|
parts.append(
|
||||||
"Fichiers de la tâche en cours : " + ", ".join(session_files[:8])
|
"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
|
# 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
|
# 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.
|
# « l'agent s'arrête sans rien modifier » sur une reprise de code.
|
||||||
if prev_code and not use_code:
|
if prev_code and not use_code:
|
||||||
convo.insert(1, {
|
extras.append(
|
||||||
"role": "system",
|
"Cette session travaille sur du code existant du workspace. "
|
||||||
"content": (
|
"Pour toute demande de modification ou d'ajout : AGIS avec "
|
||||||
"Cette session travaille sur du code existant du workspace. "
|
"les outils — code_task pour un changement multi-fichiers, "
|
||||||
"Pour toute demande de modification ou d'ajout : AGIS avec "
|
"edit_file pour un changement ciblé, write_file pour un "
|
||||||
"les outils — code_task pour un changement multi-fichiers, "
|
"nouveau fichier. Lis le fichier concerné avant de le "
|
||||||
"edit_file pour un changement ciblé, write_file pour un "
|
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
|
||||||
"nouveau fichier. Lis le fichier concerné avant de le "
|
"code corrigé dans ta réponse sans l'avoir écrit dans le "
|
||||||
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
|
"fichier."
|
||||||
"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).
|
# Skill : méthode experte injectée pour ce tour (jamais persistée).
|
||||||
if cfg.get("skills_enabled", True):
|
if cfg.get("skills_enabled", True):
|
||||||
skill = skills.pick_skill(req.content)
|
skill = skills.pick_skill(req.content)
|
||||||
if skill:
|
if skill:
|
||||||
convo.insert(1, {
|
extras.append("Méthode à suivre pour cette tâche :\n" + skill["body"])
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"Méthode à suivre pour cette tâche :\n" + skill["body"]
|
|
||||||
),
|
|
||||||
})
|
|
||||||
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
|
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
|
||||||
|
|
||||||
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
|
# 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.
|
# (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)
|
is_code_like = use_code or msg_router.is_code_task(req.content)
|
||||||
if cfg.get("ponytail", True) and is_code_like:
|
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
|
# Appli web : contraintes dures (fichier autonome, zéro dépendance
|
||||||
# externe, rendu réel) pour que ça marche vraiment hors-ligne.
|
# externe, rendu réel) pour que ça marche vraiment hors-ligne.
|
||||||
want_web = is_code_like and skills.is_web_task(req.content)
|
want_web = is_code_like and skills.is_web_task(req.content)
|
||||||
if want_web:
|
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
|
# 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
|
# 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":
|
if memory_mode == "always":
|
||||||
block = memory_notes.recall_block(req.content)
|
block = memory_notes.recall_block(req.content)
|
||||||
if block:
|
if block:
|
||||||
convo.insert(1, {"role": "system", "content": block})
|
extras.append(block)
|
||||||
|
|
||||||
if plan:
|
if plan:
|
||||||
yield _sse("plan", {"steps": plan})
|
yield _sse("plan", {"steps": plan})
|
||||||
@@ -498,17 +527,18 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if plan:
|
if plan:
|
||||||
convo.append({
|
extras.append(
|
||||||
"role": "system",
|
"Plan à suivre pour cette demande, étape par étape :\n"
|
||||||
"content": (
|
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
|
||||||
"Plan à suivre pour cette demande, étape par étape :\n"
|
+ "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est "
|
||||||
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
|
"réellement accomplie, écris sur une ligne seule "
|
||||||
+ "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est "
|
"« ✅ Étape N terminée » (N = son numéro) avant de passer à "
|
||||||
"réellement accomplie, écris sur une ligne seule "
|
"la suivante. N'annonce jamais une étape terminée à l'avance."
|
||||||
"« ✅ É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 = ""
|
final_content = ""
|
||||||
tools_meta: list[dict] = []
|
tools_meta: list[dict] = []
|
||||||
|
|||||||
Reference in new issue
Block a user