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)
+ }
+ }
+}