From 7dd319a05878ac28723c9d0deb6050b7ca3689e4 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 01:31:00 +0200 Subject: [PATCH] =?UTF-8?q?Cache=20:=20la=20ligne=20MCP=20du=20pr=C3=A9amb?= =?UTF-8?q?ule=20ne=20manque=20plus=20au=20premier=20tour=20apr=C3=A8s=20u?= =?UTF-8?q?n=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le préambule système était bâti (InjectSkills → baseSystemPrompt) AVANT que runChat n'appelle EnabledTools, donc avant que mcpTools n'ouvre les connexions. mcpPromptLine lisait le pool encore vide : le premier tour après un démarrage partait sans la ligne MCP, le second avec — tout le prompt à recalculer pour une ligne. Elle comptait en plus les outils masqués par l'utilisateur, et s'affichait pour le planner, qui ne reçoit aucun outil MCP. - prepareTurn calcule les outils UNE fois par tour, puis le préambule à partir d'eux ; runChatTools les reprend tels quels (pas de second passage par mcpEnsureAll, qui retentait deux fois un serveur en panne). - mcpPromptLine se déduit de cette tranche : seuls les outils réellement envoyés sont comptés, et rien n'est annoncé sans outil MCP. - trackerList départage les égalités d'horodatage par nom puis slug : la liste part dans le contexte, un ordre tiré au sort changeait le prompt. - Le commentaire de tasks_run.go ne prétend plus que le préfixe d'une tâche égale celui du chat (dossier de travail et taskCaps diffèrent). Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_cmd.go | 3 +- internal/loki/chat_conversation.go | 3 +- internal/loki/chat_prompt_size_test.go | 9 +-- internal/loki/chat_tools.go | 11 ++-- internal/loki/code_verify.go | 3 +- internal/loki/llm_client.go | 24 ++++++-- internal/loki/mcp_client.go | 44 ++++++++++---- internal/loki/mcp_prompt_line_test.go | 81 +++++++++++++++++++++++++ internal/loki/normalize_project_test.go | 2 +- internal/loki/tasks_run.go | 13 ++-- internal/loki/tracker.go | 14 ++++- internal/loki/tracker_test.go | 29 +++++++++ 12 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 internal/loki/mcp_prompt_line_test.go diff --git a/internal/loki/chat_cmd.go b/internal/loki/chat_cmd.go index 51b487c..92eef0f 100644 --- a/internal/loki/chat_cmd.go +++ b/internal/loki/chat_cmd.go @@ -73,7 +73,8 @@ func cmdChat(args []string) error { msgs = compacted fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]")) } - extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool { + sent, tools := prepareTurn(msgs, caps) + extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 89ca3d6..cffb577 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -821,7 +821,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) — // pas de vérification du mode Code avant sa réponse. asked := false - extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { + sent, tools := prepareTurn(final, caps) + extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()}) diff --git a/internal/loki/chat_prompt_size_test.go b/internal/loki/chat_prompt_size_test.go index 34b9d6d..dd8c6c3 100644 --- a/internal/loki/chat_prompt_size_test.go +++ b/internal/loki/chat_prompt_size_test.go @@ -50,8 +50,9 @@ const promptCharBudget = 11000 // ~2750 tokens, tout allumé func TestSystemPromptStaysLean(t *testing.T) { caps := Caps{Agent: true, Internet: true, Mem: MemAlways} - sp := baseSystemPrompt(caps) - tb, err := json.Marshal(EnabledTools(caps)) + tools := EnabledTools(caps) + sp := baseSystemPrompt(caps, tools) + tb, err := json.Marshal(tools) if err != nil { t.Fatal(err) } @@ -69,7 +70,7 @@ func TestSystemPromptStaysLean(t *testing.T) { // Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent // dans la MÊME requête et les décrivent déjà un par un. func TestSystemPromptDoesNotRelistTools(t *testing.T) { - sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}) + sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}, nil) for _, line := range strings.Split(sp, "\n") { l := strings.TrimSpace(line) if !strings.HasPrefix(l, "- ") { @@ -88,7 +89,7 @@ func TestSystemPromptDoesNotRelistTools(t *testing.T) { // ordonne d'appeler des outils invente des appels textuels qui fuient dans la // réponse. func TestSystemPromptEmptyWithoutTools(t *testing.T) { - if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" { + if sp := baseSystemPrompt(Caps{Mem: MemOff}, nil); sp != "" { t.Fatalf("préambule non vide sans outils : %q", sp) } } diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index 7ad339e..904b095 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -26,7 +26,10 @@ const ( // ("Wait, let me check… Wait, I'll just run it…") and commits to an action. // The per-tool "Outil disponible" sections live in machine/skills prompts so // they only appear when the matching feature is on. -func baseSystemPrompt(caps Caps) string { +// +// tools = les outils annoncés sur ce même tour (EnabledTools) : ce qui en est +// dit ici (la ligne MCP) en est déduit, jamais relu ailleurs. +func baseSystemPrompt(caps Caps, tools []Tool) string { hasMem := caps.Mem != MemOff // No tool access at all → no agentic preamble. A plain chat model told to // "call tools immediately" hallucinates textual tool calls (e.g. @@ -119,10 +122,10 @@ func baseSystemPrompt(caps Caps) string { if caps.Agent && caps.ComputerUse { b.WriteString(cuPromptLine()) } + // Ligne MCP tirée des outils de CE tour (voir mcpPromptLine) : un rôle sans + // outil MCP n'en reçoit pas, et le premier tour après un démarrage l'a déjà. if caps.Agent { - if line := mcpPromptLine(); line != "" { - b.WriteString(line) - } + b.WriteString(mcpPromptLine(tools)) } b.WriteString("\nDate: " + time.Now().Format("2006-01-02")) return b.String() diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 6309fdc..7f26bd3 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -166,7 +166,8 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur } var content strings.Builder sawUsage := false - extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { + sent, tools := prepareTurn(final, caps) + extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool { if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 { sawUsage = true } diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index c2ccb7d..15b18a0 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -233,13 +233,13 @@ func globalCaps() Caps { // InjectSkills prepends context system messages to msgs: the decisive-agent // preamble + machine briefing (when tools are enabled) and the lightweight // skills directory (when skills are enabled). Merges with an existing system -// message if present. -func InjectSkills(msgs []Message, caps Caps) []Message { +// message if present. tools = the tools sent on this same turn (see prepareTurn). +func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message { var parts []string // Decisive-agent preamble (anti-loop) — only when the model actually has // tools, otherwise it nudges a plain chat model to "call tools" it doesn't // have, which leaks malformed tool-call text into the answer. - if bp := baseSystemPrompt(caps); bp != "" { + if bp := baseSystemPrompt(caps, tools); bp != "" { parts = append(parts, bp) } if mp := machineSystemPrompt(caps); mp != "" { @@ -260,6 +260,17 @@ func InjectSkills(msgs []Message, caps Caps) []Message { return append([]Message{{Role: "system", Content: prefix}}, msgs...) } +// prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir +// d'eux : la séquence et les outils renvoyés vont ensemble à runChatTools. +// +// Les calculer deux fois (préambule, puis runChat) payait deux fois les +// tentatives de connexion MCP d'un serveur en panne, et laissait le préambule +// décrire un autre état que les schémas envoyés à côté. +func prepareTurn(msgs []Message, caps Caps) ([]Message, []Tool) { + tools := EnabledTools(caps) + return InjectSkills(msgs, caps, tools), tools +} + // steerSystem ajoute une consigne système SANS jamais créer un second message // système ailleurs qu'en tête : elle est fusionnée dans celui d'ouverture, ou // posée en première position s'il n'y en a pas. @@ -930,8 +941,13 @@ func toolCallLabel(name string, args map[string]any) string { // prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu // d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`. func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) { + return runChatTools(ctx, messages, EnabledTools(caps), temperature, caps, cb, injectQueued...) +} + +// runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux +// que décrit le préambule, à l'octet près. +func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) { var extra []Message - tools := EnabledTools(caps) // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` // in streaming mode: the model's block (opened by the chat template) // arrives inline in `content`, terminated by a literal . When diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index 4e7c149..b37606a 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -449,22 +449,42 @@ func flattenMCPContent(res *mcpsdk.CallToolResult) string { return text } -// mcpPromptLine renvoie une ligne système listant les serveurs MCP connectés et -// leur nombre d'outils, pour situer le modèle. Ne force PAS de connexion : lit -// seulement l'état déjà établi par mcpTools() (appelé par EnabledTools sur le -// même tour), pour ne pas payer deux fois le handshake. -func mcpPromptLine() string { - mcpMgr.mu.Lock() - defer mcpMgr.mu.Unlock() - var names []string - for name, s := range mcpMgr.sessions { - if s.sess != nil && len(s.tools) > 0 { - names = append(names, fmt.Sprintf("%s (%d)", name, len(s.tools))) +// mcpPromptLine renvoie une ligne système listant les serveurs MCP annoncés et +// leur nombre d'outils, pour situer le modèle. Elle se DÉDUIT des outils +// réellement envoyés ce tour-ci (EnabledTools), pas de l'état du pool. +// +// Lue dans le pool, elle arrivait en retard d'un tour : le préambule est bâti +// avant que mcpTools n'ouvre les connexions, si bien que le premier tour après +// un démarrage partait sans la ligne et le second avec — tout le prompt à +// recalculer pour une ligne. Elle comptait aussi les outils masqués par +// l'utilisateur, et s'affichait pour un rôle (planner…) qui ne reçoit aucun +// outil MCP. Tirée de la même tranche que les schémas, elle ne peut plus les +// contredire. +func mcpPromptLine(tools []Tool) string { + counts := map[string]int{} + for _, t := range tools { + if !strings.HasPrefix(t.Function.Name, "mcp__") { + continue } + // mcpTools préfixe chaque description par « [MCP: ] » : le nom + // d'origine du serveur, que le nom d'outil (assaini) ne garde pas. + rest, ok := strings.CutPrefix(t.Function.Description, "[MCP: ") + if !ok { + continue + } + server, _, ok := strings.Cut(rest, "] ") + if !ok { + continue + } + counts[server]++ } - if len(names) == 0 { + if len(counts) == 0 { return "" } + names := make([]string, 0, len(counts)) + for name, n := range counts { + names = append(names, fmt.Sprintf("%s (%d)", name, n)) + } sort.Strings(names) return "\n\nMCP servers connected (their tools are prefixed mcp____): " + strings.Join(names, ", ") + "." } diff --git a/internal/loki/mcp_prompt_line_test.go b/internal/loki/mcp_prompt_line_test.go new file mode 100644 index 0000000..a8211f9 --- /dev/null +++ b/internal/loki/mcp_prompt_line_test.go @@ -0,0 +1,81 @@ +package loki + +import ( + "strings" + "testing" +) + +func mcpTestTool(server, tool string) Tool { + return Tool{Type: "function", Function: ToolFunction{ + Name: mcpExposedName(server, tool), + Description: "[MCP: " + server + "] décrit " + tool, + }} +} + +// La ligne MCP du préambule se déduit des outils envoyés sur le MÊME tour : elle +// ne peut ni manquer au premier tour après un démarrage (pool encore vide quand +// le préambule était bâti), ni compter un outil masqué, ni apparaître pour un +// rôle qui ne reçoit aucun outil MCP. +func TestMCPPromptLineDeriveDesOutils(t *testing.T) { + cases := []struct { + name string + tools []Tool + want string + }{ + {"aucun outil", nil, ""}, + {"aucun outil MCP", []Tool{bashTool(), readTool()}, ""}, + {"un serveur", []Tool{bashTool(), mcpTestTool("github", "issues"), mcpTestTool("github", "prs")}, + "github (2)"}, + {"plusieurs serveurs, triés", []Tool{mcpTestTool("zeta", "a"), mcpTestTool("alpha.io", "b"), mcpTestTool("zeta", "c")}, + "alpha.io (1), zeta (2)"}, + {"description sans le préfixe [MCP: …] ignorée", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "rien"}}}, + ""}, + {"description vide après le préfixe", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "[MCP: x] "}}}, + "x (1)"}, + } + for _, c := range cases { + got := mcpPromptLine(c.tools) + if c.want == "" { + if got != "" { + t.Errorf("%s : ligne %q, attendu aucune", c.name, got) + } + continue + } + if !strings.HasSuffix(got, ": "+c.want+".") { + t.Errorf("%s : ligne %q, attendu la liste %q", c.name, got, c.want) + } + } +} + +// Le préambule et les outils du tour viennent de la même tranche : le rôle +// planner (aucun outil MCP) n'a pas de ligne MCP, le fil principal l'a dès le +// premier tour. +func TestBaseSystemPromptLigneMCPSuitLesOutils(t *testing.T) { + caps := Caps{Agent: true, Mem: MemOff} + with := append(EnabledTools(caps), mcpTestTool("github", "issues")) + if sp := baseSystemPrompt(caps, with); !strings.Contains(sp, "MCP servers connected") || !strings.Contains(sp, "github (1)") { + t.Fatalf("ligne MCP absente alors qu'un outil MCP part sur ce tour :\n%s", sp) + } + if sp := baseSystemPrompt(caps, EnabledTools(caps)); strings.Contains(sp, "MCP servers") { + t.Fatalf("ligne MCP sans aucun outil MCP envoyé :\n%s", sp) + } + planner := Caps{Agent: true, Code: true, Role: "planner", Mem: MemOff} + if sp := baseSystemPrompt(planner, EnabledTools(planner)); strings.Contains(sp, "MCP servers") { + t.Fatalf("le planner ne reçoit aucun outil MCP, sa ligne ne doit pas l'annoncer :\n%s", sp) + } +} + +// prepareTurn rend la séquence ET les outils qu'elle décrit : le préambule +// injecté est exactement celui de baseSystemPrompt sur ces outils. +func TestPrepareTurnPreambuleCoherentAvecOutils(t *testing.T) { + testHome(t) + caps := Caps{Agent: true, Mem: MemOff} + sent, tools := prepareTurn([]Message{{Role: "user", Content: "salut"}}, caps) + if len(sent) != 2 || sent[0].Role != "system" { + t.Fatalf("séquence inattendue : %#v", sent) + } + sys, _ := sent[0].Content.(string) + if !strings.HasPrefix(sys, baseSystemPrompt(caps, tools)) { + t.Fatalf("le préambule ne correspond pas aux outils renvoyés :\n%s", sys) + } +} diff --git a/internal/loki/normalize_project_test.go b/internal/loki/normalize_project_test.go index 41b24e4..2022f27 100644 --- a/internal/loki/normalize_project_test.go +++ b/internal/loki/normalize_project_test.go @@ -43,7 +43,7 @@ func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) { {Role: "system", Content: projectContextPrefix + " — projet A"}, {Role: "user", Content: "bonjour"}, } - out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true})) + out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}, nil)) 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]) } diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index bd9578c..31d6006 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -69,9 +69,11 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom temperature = 0.7 } // La note de tâche (conscience du mode autonome + compte-rendu du passage - // précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le - // préfixe système + outils reste ainsi identique à celui du chat, et le cache - // de prompt de llama-server sert aux deux. Une note dans le système faisait + // précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : elle + // n'ajoute aucun écart au préfixe système + outils. Il n'est pas pour autant + // celui du chat : le briefing machine porte le dossier de travail de la + // tâche, et taskCaps ne reprend ni le pilotage du navigateur ni une mémoire + // coupée. Une note dans le système faisait // tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s // mesurées en amont pour un simple « salut », AJEAN 0.17.5). if note := taskContextNote(taskName, lastReport); note != "" { @@ -79,7 +81,7 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom } msgs := []Message{{Role: "user", Content: prompt}} // Même préambule que la vraie génération : consigne personnelle + préambule - // agent + briefing machine (via InjectSkills), pour que l'IA ait le même + // agent + briefing machine (via prepareTurn), pour que l'IA ait le même // contexte et les mêmes outils qu'en chat. // Contexte du projet de la tâche : le projet est déjà forcé par l'appelant // (setProjectOverride dans runTask), donc ces messages décrivent le bon @@ -93,7 +95,8 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom // La tâche a pris le slot de la conversation : effacé une fois la tâche // finie, verrou de génération encore tenu (llm_slots.go). defer engineSideJob()() - _, err := runChat(withPerf(ctx, perfTask, "task:"+taskID), InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { + sent, tools := prepareTurn(final, caps) + _, err := runChatTools(withPerf(ctx, perfTask, "task:"+taskID), sent, tools, temperature, caps, func(ev StreamEvent) bool { if ev.Content != "" { content.WriteString(ev.Content) } diff --git a/internal/loki/tracker.go b/internal/loki/tracker.go index fd9d849..3617d74 100644 --- a/internal/loki/tracker.go +++ b/internal/loki/tracker.go @@ -107,7 +107,19 @@ func trackerList() []trackerMeta { } out = append(out, m) } - sort.SliceStable(out, func(i, j int) bool { return out[i].LastTS > out[j].LastTS }) + // Départage par nom puis slug : allKV parcourt une map, et deux trackers au + // même horodatage (deux points datés du même jour, sans heure) sortaient dans + // un ordre tiré au sort. Cette liste part dans le contexte du modèle — un ordre qui + // change d'un tour à l'autre change le prompt, et tout le cache derrière. + sort.SliceStable(out, func(i, j int) bool { + if out[i].LastTS != out[j].LastTS { + return out[i].LastTS > out[j].LastTS + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].Slug < out[j].Slug + }) return out } diff --git a/internal/loki/tracker_test.go b/internal/loki/tracker_test.go index 727ef87..f85955a 100644 --- a/internal/loki/tracker_test.go +++ b/internal/loki/tracker_test.go @@ -289,3 +289,32 @@ func TestStrTolerantAuType(t *testing.T) { t.Fatalf("str(\"texte\") = %q", got) } } + +// Deux trackers au même horodatage (points datés du même jour, sans heure) +// sortent toujours dans le même ordre — par nom — quel que soit l'ordre de +// parcours du stockage. La liste part dans le contexte du modèle : un ordre +// tiré au sort changerait le prompt d'un tour à l'autre. +func TestTrackerListOrdreStableAEgalite(t *testing.T) { + newProjectHome(t) + ensureDefaultProject() + + for _, name := range []string{"zinc", "azote", "magnésium"} { + if _, err := trackerAdd(name, "2026-05-04", "1"); err != nil { + t.Fatal(err) + } + } + if _, err := trackerAdd("récent", "2026-06-01", "2"); err != nil { + t.Fatal(err) + } + want := []string{"récent", "azote", "magnésium", "zinc"} + for round := 0; round < 20; round++ { + list := trackerList() + var got []string + for _, m := range list { + got = append(got, m.Name) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("ordre %v, attendu %v (le plus récent d'abord, puis par nom)", got, want) + } + } +}