mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
23 KiB
Go
485 lines
23 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")
|
|
if caps.envInCtx {
|
|
// Même consigne, sans la valeur : l'année est dans la ligne de date du
|
|
// contexte (envContextMessage), qui nomme aussi l'année périmée.
|
|
b.WriteString("Today's date is in your context. If a query needs a year use ONLY the current year, never a remembered past 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")
|
|
} else {
|
|
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))
|
|
}
|
|
// PROJ_SNAPSHOT (chat_projsnap.go) : le bloc projet est figé, ses changements
|
|
// arrivent en tête des messages suivants. Une ligne fixe, seulement avec la
|
|
// clé, pour qu'ils ne passent ni pour la parole de l'utilisateur ni pour
|
|
// moins récents que le bloc.
|
|
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
|
b.WriteString(projSnapSystemLine)
|
|
}
|
|
// PROJ_SNAPSHOT : la date part dans le bloc projet figé (chat_envctx.go). Ici,
|
|
// elle changeait le système à minuit — tout le prompt recalculé.
|
|
if caps.envInCtx {
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
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
|
|
// PROJ_SNAPSHOT (caps.envInCtx) : nom, système, dossier et état du poste
|
|
// sont dans le bloc projet (envMachineFacts) ; ici, les seules consignes.
|
|
if caps.envInCtx {
|
|
b.WriteString("Machine: you are operating on a REMOTE node (name, OS, working folder and status are in your context)")
|
|
} else {
|
|
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 != "" {
|
|
if caps.envInCtx {
|
|
b.WriteString(" File paths in write/edit resolve inside its working folder; read/write are confined there.")
|
|
} else {
|
|
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 && !caps.envInCtx {
|
|
b.WriteString(" " + nodeOfflineLine)
|
|
}
|
|
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)
|
|
}
|
|
// PROJ_SNAPSHOT (caps.envInCtx) : le chemin, propre à chaque discussion, est
|
|
// dans le bloc projet (envMachineFacts) ; hôte et compte, les mêmes pour
|
|
// toutes, restent ici avec les consignes.
|
|
if cwd != "" && !caps.envInCtx {
|
|
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.
|
|
if caps.envInCtx {
|
|
b.WriteString(" Your working folder (cwd) is given in your context: relative paths in write/edit/bash resolve there")
|
|
} else {
|
|
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here")
|
|
}
|
|
b.WriteString(", 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()
|
|
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
|
|
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
|
|
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
|
|
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
|
|
detached := errors.Is(err, exec.ErrWaitDelay)
|
|
if detached {
|
|
err = nil
|
|
}
|
|
// 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)
|
|
}
|
|
if detached {
|
|
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
|
|
}
|
|
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:])
|
|
}
|