Files
Loki/internal/loki/chat_tools.go
T
MichaelandClaude Opus 5.5 7dd319a058 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>
2026-10-04 01:31:00 +02:00

438 lines
20 KiB
Go

package loki
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
toolDefaultTimeout = 30
toolMaxTimeout = 300
toolMaxOutput = 8000 // characters of stdout/stderr returned to the model
)
// baseSystemPrompt is the always-on system preamble. Structured like pi's
// proven prompt (identity → method → guidelines → concision → date): a concrete,
// procedural prompt gives the model rails so it stops deliberating forever
// ("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.
//
// 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.
// default_api:bash) that leak into the answer. Let the user's own system
// prompt stand alone. (Internet requiert l'agent, donc pas testé ici.)
if !caps.Agent && !hasMem {
return ""
}
var b strings.Builder
// Prompt VOLONTAIREMENT court. Un préambule verbeux (longue liste de
// « guidelines », surtout des méta-instructions sur la réflexion) fait
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis loki »).
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
// de l'identité. Court par construction (voir code_roles.go) — la règle
// « ne pas regonfler » vaut aussi pour lui.
if caps.Code {
role := caps.Role
if role == "" {
role = "builder"
}
if rp := rolePrompt(role); rp != "" {
b.WriteString("\n\n" + rp)
}
}
if memProactive(caps.Mem) {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
// les décrivent déjà un par un. Les réénumérer coûtait ~120 tokens à chaque
// tour pour répéter ce que le modèle a juste à côté. Ne restent que les
// consignes que les schémas ne portent pas — le shell utilisé, et les
// politiques d'usage.
if caps.Agent {
// Le shell de la machine CIBLE (cmd.exe pour un poste Windows), pas celui du
// serveur : sinon le modèle écrit du bash là où tourne cmd.exe (et inversement).
b.WriteString("\n\nThe shell is " + agentTargetShellName() + ": use its syntax.\n")
} else {
b.WriteString("\n\n")
}
// Politique d'usage de la mémoire selon le mode.
switch caps.Mem {
case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
// recherche devient facultative — l'IA lit directement la bonne page.
b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n")
case MemSearchFirst:
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
// elle-même, sinon elle ignore ce que sa mémoire contient.
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
case MemOnDemand:
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
}
if caps.Agent {
// La règle « write, jamais echo/cat » est INDISPENSABLE (cmd.exe massacre
// les guillemets imbriqués) mais elle vit maintenant dans les schémas de
// write et bash, là où elle s'applique. Elle était écrite trois fois.
b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
// Un lien Markdown ordinaire, comme dans n'importe quel chat : l'UI en fait
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
// à connaître pour le modèle — juste [texte](chemin).
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
// disent pas. Une ligne, pas trois : le budget du préambule est compté
// (TestSystemPromptStaysLean).
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
if caps.Mem == MemSearchFirst {
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
}
}
if caps.Internet {
// Le catalogue des outils web est parti dans leurs schémas ; ne reste ici
// que l'ordre d'appel, que les schémas pris isolément ne disent pas.
year := time.Now().Format("2006")
b.WriteString("\nWeb: web_open first, then web_read/web_grep on it.\n")
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
}
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 {
b.WriteString(mcpPromptLine(tools))
}
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
}
// prevYear returns the year before the given "2006"-formatted year string, used
// to name explicitly the stale year the model must NOT put in search queries.
func prevYear(year string) string {
n, err := strconv.Atoi(year)
if err != nil {
return year
}
return strconv.Itoa(n - 1)
}
// machineSystemPrompt returns a short briefing about the host the model is
// running on, so that when machine access is enabled it knows *which* machine
// run_shell acts upon (and doesn't claim it has no access to "your PC").
// Returns "" when machine access is off.
func machineSystemPrompt(caps Caps) string {
if !caps.Agent {
return ""
}
// Cible = un poste distant : bash/write/edit s'exécutent LÀ-BAS, pas sur ce
// serveur. Le modèle doit le savoir explicitement, sinon il croit agir sur
// l'hôte du serveur et se trompe de machine.
if tgt, ok := nodeTargetMetaGet(); ok {
var b strings.Builder
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
if tgt.os != "" {
b.WriteString(" (" + tgt.os + ")")
}
b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
}
if tgt.root != "" {
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
}
// Le poste peut tourner en compte de service (Windows: LocalSystem) : les
// variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
// PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\<name>\\...).")
if !tgt.connected {
b.WriteString(" ⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly.")
}
return b.String()
}
host, _ := os.Hostname()
if host == "" {
host = "unknown"
}
who := ""
if u, err := user.Current(); err == nil {
who = u.Username
}
cwd := agentCwd()
var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
if who != "" {
b.WriteString(", user=" + who)
}
if cwd != "" {
b.WriteString(", cwd=" + cwd)
}
b.WriteString(".")
if cwd != "" {
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
// aux « installations » en /usr/local/bin qui échouent faute de root.
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
}
return b.String()
}
// runShell executes a command via the platform shell (bash -c on Unix, cmd /C
// on Windows — see newShellCmd in sys_platform_*.go) with a clamped timeout,
// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..."
// truncated to keep tool output bounded.
//
// ⚠️ parent est le contexte DU TOUR : c'est lui qui rend le bouton stop utile.
// La commande naissait auparavant d'un context.Background(), donc arrêter la
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
// délai (5 minutes au maximum), bouton stop sans effet.
func runShell(parent context.Context, command string, timeoutSec int) string {
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
// n'avait pas servie.
if msg := guardToolOnlyCommand(command); msg != "" {
return msg
}
if timeoutSec <= 0 {
timeoutSec = toolDefaultTimeout
}
if timeoutSec > toolMaxTimeout {
timeoutSec = toolMaxTimeout
}
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
command, keepLines := splitTailPipe(command)
start := time.Now()
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
// atterrir sur le Bureau, ni dans une AUTRE discussion.
//
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
// supprime — toutes les commandes suivantes échouaient sur un « chdir : no
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
// tout refuser.
if ws := agentCwd(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws
}
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// ⚠️ WaitDelay borne l'attente APRÈS la fin (ou la mise à mort) du process.
// Sans elle, Wait attend que les tubes de sortie soient fermés — donc que
// TOUS ceux qui les tiennent aient disparu, petits-enfants compris. Une
// commande du genre « ./serveur & » rend la main tout de suite mais laisse
// un process en arrière-plan accroché aux tubes : runShell ne revenait alors
// JAMAIS, ni au délai, ni au stop. Le tour restait bloqué à vie, et la seule
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
out := stdout.String()
if keepLines > 0 {
out = tailLines(out, keepLines)
}
out = clean(out)
errOut := clean(stderr.String())
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
// La sortie déjà produite reste utile (où le build en était, quel test
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
if out != "" {
msg += "\n\nstdout (partiel):\n" + out
}
if errOut != "" {
msg += "\n\nstderr (partiel):\n" + errOut
}
return msg
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
exit := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
exit = ee.ExitCode()
} else {
return fmt.Sprintf("[erreur: %v]", err)
}
}
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
if out != "" {
parts = append(parts, "stdout:\n"+out)
}
if errOut != "" {
parts = append(parts, "stderr:\n"+errOut)
}
return strings.Join(parts, "\n\n")
}
// shellName is the shell runShell actually spawns on this platform. The model is
// told this explicitly: advertising the tool as "bash" on Windows made it emit
// bash quoting into cmd.exe, which mangles it (unterminated string literals, and
// stray "Commande ECHO activée." landing inside generated files).
func shellName() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "bash"
}
// fileWrite writes content to path verbatim, creating parent directories and
// replacing any existing file. This is the escape hatch from shell quoting: a
// model with only a shell has to build files with echo/python -c, which is
// unreliable everywhere and outright broken on cmd.exe.
func fileWrite(path, content string) string {
if strings.TrimSpace(path) == "" {
return "[erreur] chemin vide"
}
path = resolveAgentPath(path)
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
if msg := guardToolOnlyPath(path); msg != "" {
return msg
}
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "[erreur] " + err.Error()
}
}
// Préserve les permissions d'origine quand le fichier existe déjà (un script
// 0755 réécrit doit rester exécutable).
mode := os.FileMode(0o644)
existed := false
if fi, err := os.Stat(path); err == nil {
mode = fi.Mode()
existed = true
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return "[erreur] " + err.Error()
}
verb := "créé"
if existed {
verb = "réécrit"
}
return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content))
}
// fileEdit applies a single exact-text replacement to a file on disk: oldText
// must appear EXACTLY once (otherwise it errors), so the model can patch a file
// without rewriting it whole. Returns a short status string for the tool result.
func fileEdit(path, oldText, newText string) string {
if strings.TrimSpace(path) == "" {
return "[erreur] chemin vide"
}
if oldText == "" {
return "[erreur] old vide"
}
path = resolveAgentPath(path)
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
if msg := guardToolOnlyPath(path); msg != "" {
return msg
}
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
}
content := string(b)
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
crlf := strings.Contains(content, "\r\n")
search, oldN, newN := content, oldText, newText
if crlf && !strings.Contains(oldText, "\r\n") {
search = strings.ReplaceAll(content, "\r\n", "\n")
}
n := strings.Count(search, oldN)
if n == 0 {
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
// une erreur, sinon le modèle croit avoir échoué et recommence.
if newN != "" && strings.Contains(search, newN) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
}
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(search, oldN, newN, 1)
if search != content {
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
updated = strings.ReplaceAll(updated, "\n", "\r\n")
}
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
mode := os.FileMode(0o644)
if fi, err := os.Stat(path); err == nil {
mode = fi.Mode()
}
if err := os.WriteFile(path, []byte(updated), mode); err != nil {
return "[erreur] " + err.Error()
}
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
}
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
// ~2004 tok sans qu'on sache pourquoi.
func tailOutput(s string) string {
n := len([]rune(s))
if n <= toolMaxOutput {
return s
}
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
}
// tailRunes returns the last n runes of s (used to cap tool output).
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}