Cache : la ligne MCP du préambule ne manque plus au premier tour après un démarrage

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 01:31:00 +02:00
1 parent 9263df404d
commit 7dd319a058
12 files changed
+202 -34

No files matched your search

+2 -1
View File
@@ -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()))
+2 -1
View File
@@ -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()})
+5 -4
View File
@@ -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)
}
}
+7 -4
View File
@@ -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()
+2 -1
View File
@@ -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
}
+20 -4
View File
@@ -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 <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. When
+32 -12
View File
@@ -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: <serveur>] » : 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__<server>__<tool>): " + strings.Join(names, ", ") + "."
}
+81
View File
@@ -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)
}
}
+1 -1
View File
@@ -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])
}
+8 -5
View File
@@ -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)
}
+13 -1
View File
@@ -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
}
+29
View File
@@ -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)
}
}
}