Projets : changer de projet ne recalcule plus tout le prompt

Repris d'AJEAN 0.15.8, adapté : chez nous le contexte projet n'est jamais
persisté, il est injecté à chaque tour sous forme de messages système
préfixés — ce qui rend le déplacement trivial et sans migration.

LE PROBLÈME
Sur un modèle hybride (Qwen3.5 et suivants, couches récurrentes), llama.cpp
ne peut reprendre un prompt déjà calculé qu'à un point de sauvegarde, et il
n'en pose qu'au DÉBUT d'un message utilisateur. La description du projet,
l'index mémoire et l'index des trackers étaient fusionnés dans le bloc
système : deux projets divergeaient AVANT le premier point de reprise, et le
premier message après un changement de projet recalculait tout (l'amont
mesure ~8 s pour 5 000 tokens sur un 27B).

LE CORRECTIF
normalizeSystemMessages sort ces messages (isProjectSystem, par leurs
préfixes) du bloc système et les place, dans un bloc <project_context>, en
tête du PREMIER message utilisateur de la séquence envoyée. Le système
commun reste identique d'un projet à l'autre, donc en cache. L'historique
persisté et l'affichage ne changent pas (copie, entrée non mutée — testé).

InjectSkills ne fusionne plus son préambule DANS un message projet placé en
tête (cas d'un preset sans prompt système) : il y aurait perdu son préfixe
et serait resté dans le bloc commun.

Le gain ne vaut qu'entre projets de même mode mémoire : la consigne mémoire
fait partie du système commun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:04:51 +02:00
1 parent 4602614be0
commit 626227f2f2
2 files changed
+117 -3

No files matched your search

+64 -3
View File
@@ -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 := "<project_context>\n" + ctx + "\n</project_context>\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{}
+53
View File
@@ -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, "<project_context>\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])
}
}