diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 06b8bcc..9e53f48 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -244,7 +244,10 @@ func InjectSkills(msgs []Message, caps Caps) []Message { return msgs } prefix := strings.Join(parts, "\n\n") - if len(msgs) > 0 && msgs[0].Role == "system" { + // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le + // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait + // plus le sortir du bloc système commun (voir isProjectSystem). + if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { existing, _ := msgs[0].Content.(string) merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) return merged @@ -287,15 +290,29 @@ func steerSystem(msgs []Message, hint string) []Message { // n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc // sans risque pour tous, et sans effet sur une conversation déjà normale (payload // identique, garanti par test). +// +// Ce qui dépend du PROJET (description, index mémoire, trackers) ne va PAS dans +// ce bloc mais en tête du premier message utilisateur (AJEAN 0.15.8). Sur un +// modèle hybride (Qwen3.5+, couches récurrentes), llama.cpp ne sait reprendre un +// prompt qu'à un point de sauvegarde, et il n'en pose qu'au DÉBUT des messages +// utilisateur. Laissé dans le système, le contexte projet faisait diverger le +// prompt au milieu du bloc : changer de projet recalculait tout. Au début du 1er +// message user, la divergence tombe pile sur un point de sauvegarde, après le +// système commun, qui reste en cache. func normalizeSystemMessages(msgs []Message) []Message { - var sys []string + var sys, proj []string sawSystem := false rest := make([]Message, 0, len(msgs)) for _, m := range msgs { if m.Role == "system" { if s, ok := m.Content.(string); ok { sawSystem = true - if strings.TrimSpace(s) != "" { + if strings.TrimSpace(s) == "" { + continue + } + if isProjectSystem(m) { + proj = append(proj, s) + } else { sys = append(sys, s) } continue @@ -310,6 +327,12 @@ func normalizeSystemMessages(msgs []Message) []Message { if !sawSystem { return msgs } + if len(proj) > 0 { + if !prependToFirstUser(rest, strings.Join(proj, "\n\n")) { + // Pas encore de message user (cas théorique) : on les garde en système. + sys = append(sys, proj...) + } + } // Des systèmes existaient mais tous vides : on les a retirés (un système vide // hors position 0 casserait tout autant), sans en réinsérer. if len(sys) == 0 { @@ -320,6 +343,44 @@ func normalizeSystemMessages(msgs []Message) []Message { return append(out, rest...) } +// isProjectSystem : message système propre au projet actif (description, index +// mémoire, trackers), à sortir du bloc système commun (voir +// normalizeSystemMessages). +func isProjectSystem(m Message) bool { + s, ok := m.Content.(string) + if !ok || m.Role != "system" { + return false + } + return strings.HasPrefix(s, projectContextPrefix) || + strings.HasPrefix(s, memIndexPrefix) || + strings.HasPrefix(s, trackerIndexPrefix) +} + +// prependToFirstUser place ctx en tête du premier message user de msgs (modifié +// en place : msgs est déjà une copie, mais ses Content ne sont PAS recopiés — +// on remplace donc la valeur, jamais on ne mute une tranche partagée). +// Renvoie false s'il n'y a aucun message user. +func prependToFirstUser(msgs []Message, ctx string) bool { + block := "\n" + ctx + "\n\n\n" + for i, m := range msgs { + if m.Role != "user" { + continue + } + switch c := m.Content.(type) { + case string: + msgs[i].Content = block + c + case []any: + msgs[i].Content = append([]any{map[string]any{"type": "text", "text": block}}, c...) + case []map[string]any: + msgs[i].Content = append([]map[string]any{{"type": "text", "text": block}}, c...) + default: + return false + } + return true + } + return false +} + // EnabledTools returns the tools to advertise on the next inference call. func EnabledTools(caps Caps) []Tool { tools := []Tool{} diff --git a/internal/loki/normalize_project_test.go b/internal/loki/normalize_project_test.go new file mode 100644 index 0000000..41b24e4 --- /dev/null +++ b/internal/loki/normalize_project_test.go @@ -0,0 +1,53 @@ +package loki + +import ( + "reflect" + "strings" + "testing" +) + +// Le contexte PROJET quitte le bloc système pour la tête du 1er message user : +// le système commun reste identique d'un projet à l'autre (cache de prompt), et +// l'historique d'origine n'est pas muté. +func TestNormalizeMovesProjectContextToFirstUser(t *testing.T) { + in := []Message{ + {Role: "system", Content: "Préambule commun"}, + {Role: "system", Content: projectContextPrefix + " — projet A"}, + {Role: "system", Content: memIndexPrefix + " for project A"}, + {Role: "user", Content: "bonjour"}, + {Role: "assistant", Content: "salut"}, + {Role: "user", Content: "suite"}, + } + ref := append([]Message(nil), in...) + out := normalizeSystemMessages(in) + if len(out) != 4 || out[0].Content != "Préambule commun" { + t.Fatalf("système commun attendu seul en tête : %#v", out) + } + first, _ := out[1].Content.(string) + if !strings.HasPrefix(first, "\n"+projectContextPrefix) || + !strings.Contains(first, memIndexPrefix) || !strings.HasSuffix(first, "bonjour") { + t.Fatalf("contexte projet absent du 1er message user : %q", first) + } + if out[3].Content != "suite" { + t.Fatal("seul le PREMIER message user reçoit le contexte") + } + if !reflect.DeepEqual(in, ref) { + t.Fatal("l'entrée a été mutée (interdit)") + } +} + +// Sans prompt de preset, le premier système est un message projet : le préambule +// ne doit pas l'absorber (il perdrait son préfixe et resterait dans le système). +func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) { + in := []Message{ + {Role: "system", Content: projectContextPrefix + " — projet A"}, + {Role: "user", Content: "bonjour"}, + } + out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true})) + if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) { + t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0]) + } + if u, _ := out[1].Content.(string); !strings.Contains(u, projectContextPrefix) { + t.Fatalf("contexte projet attendu dans le 1er message user : %#v", out[1]) + } +}