diff --git a/README.md b/README.md index c585cda..c833911 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa | `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous | | `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | | `REASONING` | passthrough du mode raisonnement | — | +| `REASONING_BUDGET` | plafond de tokens de réflexion (anti-boucle) ; `-1` = illimité | `2048` | | `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans `$JEAN_HOME/.api_key`, séparément de `config.env`. diff --git a/llm.go b/llm.go index dd4a290..1eb6387 100644 --- a/llm.go +++ b/llm.go @@ -86,6 +86,8 @@ func runShellTool() Tool { // skills are enabled). Merges with an existing system message if present. func InjectSkills(msgs []Message) []Message { var parts []string + // Always-on decisive-agent preamble (anti-loop). See baseSystemPrompt. + parts = append(parts, baseSystemPrompt()) if mp := machineSystemPrompt(); mp != "" { parts = append(parts, mp) } @@ -234,6 +236,10 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { // retry the same turn once with tools removed so the model answers in plain // text from the tool results already gathered, instead of dying mid-chat. disableTools := false + // Anti-loop net: if the model re-emits the exact same tool call (name+args) + // several times, it's stuck — break instead of spinning to the iteration cap. + lastSig := "" + repeatSig := 0 for iter := 0; iter < 8; iter++ { payload := map[string]any{ "model": "jean", @@ -447,6 +453,25 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { assistant.Content = s } messages = append(messages, assistant) + // Loop guard: same exact call(s) as last turn? Count it; on the 3rd + // identical turn, stop so we don't churn the same command forever. + sig := strings.Builder{} + for _, tc := range tcs { + sig.WriteString(tc.Function.Name) + sig.WriteByte('\x00') + sig.WriteString(tc.Function.Arguments) + sig.WriteByte('\n') + } + if s := sig.String(); s == lastSig { + repeatSig++ + if repeatSig >= 2 { + cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"}) + return nil + } + } else { + lastSig = s + repeatSig = 0 + } // 2. Execute each tool locally and append a "tool" reply. for _, tc := range tcs { var args map[string]any diff --git a/main.go b/main.go index 4cd4669..67b3961 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "0.2.0" +const Version = "0.2.1" func main() { args := os.Args[1:] diff --git a/serve.go b/serve.go index 43b0237..cca917d 100644 --- a/serve.go +++ b/serve.go @@ -61,11 +61,12 @@ func cmdServe(args []string) error { llmArgs = append(llmArgs, "-ctv", vtv) } if r := cfg["REASONING"]; r != "" { - // budget -1 = thinking illimité (défaut). NE PAS forcer 0 : sur llama.cpp - // vanilla, 0 = "immediate end" → coupe le raisonnement net (le fork - // ik_llama.cpp, lui, l'ignore, d'où l'ancien comportement trompeur). - // Configurable via REASONING_BUDGET (ex: 2048 pour plafonner). - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) + // budget plafonné par défaut (2048) : une réflexion qui part en boucle est + // coupée net au lieu de tourner à l'infini — c'est le comportement de pi. + // NE PAS forcer 0 : sur llama.cpp vanilla, 0 = "immediate end" → coupe tout + // le raisonnement (le fork ik_llama.cpp l'ignore). Mettre -1 pour illimité. + // Configurable via REASONING_BUDGET. + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "2048")) } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/skills.go b/skills.go index 6279ffa..80b975f 100644 --- a/skills.go +++ b/skills.go @@ -162,12 +162,20 @@ func skillsSystemPrompt() string { } list := ListSkills() var b strings.Builder - b.WriteString(`Skills = guides Markdown que tu gères toi-même via le tool skill(action, name, content) : read=lire, write=créer/remplacer, append=ajouter à la fin sans réécrire, delete=supprimer. De ta propre initiative, crée ou enrichis un skill quand tu découvres une solution réutilisable (problème enfin résolu, procédure, astuce) ; sa 1re ligne est un titre court (#) servant de description. N'utilise read que si la question concerne un skill listé.`) + b.WriteString("Outil disponible — skill(action, name, content) : gère tes guides Markdown réutilisables. action: read=lire, write=créer/remplacer, append=ajouter à la fin, delete=supprimer.\n") + b.WriteString("Conseils skill :\n") + for _, l := range []string{ + "De ta propre initiative, crée ou enrichis un skill quand tu découvres une solution réutilisable (problème résolu, procédure, astuce).", + "La 1re ligne du contenu est un titre court (#) qui sert de description.", + "N'utilise read que si la question concerne un skill listé ci-dessous — sinon n'appelle pas l'outil.", + } { + b.WriteString("- " + l + "\n") + } if len(list) == 0 { - b.WriteString("\nAucun skill pour l'instant.") + b.WriteString("Skills : aucun pour l'instant.") return b.String() } - b.WriteString("\nSkills :\n") + b.WriteString("Skills :\n") for _, s := range list { fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Desc) } diff --git a/tools.go b/tools.go index 4d47062..1178d8e 100644 --- a/tools.go +++ b/tools.go @@ -37,6 +37,30 @@ func setToolsEnabled(on bool) error { return nil } +// 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. +func baseSystemPrompt() string { + var b strings.Builder + b.WriteString("Tu es un assistant expert opérant dans jean, un agent qui agit directement sur cette machine. Tu aides l'utilisateur en inspectant le système, en exécutant des commandes et en gérant des skills réutilisables.\n\n") + b.WriteString("Méthode :\n") + for _, l := range []string{ + "Raisonne brièvement (1 à 2 phrases) puis AGIS. Dès qu'une action est possible, appelle l'outil immédiatement — ne l'annonce pas, fais-le.", + "Ne répète JAMAIS deux fois la même étape de réflexion ni le même appel d'outil. Si tu hésites (« je devrais peut-être vérifier… »), arrête de réfléchir et appelle l'outil maintenant.", + "Pour toute question système (fichiers, process, réseau, OS), utilise tes outils au lieu de supposer — tu as un accès réel, ne demande pas la permission pour inspecter.", + "Quand la tâche est faite, donne une réponse finale courte et arrête-toi. N'enchaîne pas d'outils inutiles.", + "Sois concis dans tes réponses.", + "Affiche clairement les chemins de fichiers quand tu travailles dessus.", + } { + b.WriteString("- " + l + "\n") + } + b.WriteString("\nDate du jour : " + time.Now().Format("2006-01-02")) + return b.String() +} + // 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"). @@ -56,7 +80,16 @@ func machineSystemPrompt() string { cwd, _ := os.Getwd() var b strings.Builder - b.WriteString("Accès machine actif : le tool run_shell exécute des commandes sur cette machine — c'est ton accès réel, ne dis jamais que tu n'y as pas accès. Pour toute question système (OS, disque, process, fichiers, réseau), utilise-le au lieu de supposer.\n") + b.WriteString("Outil disponible — run_shell(command, timeout) : exécute une commande shell sur CETTE machine et renvoie stdout, stderr et le code de sortie. C'est ton accès réel ; ne dis jamais que tu n'y as pas accès.\n") + b.WriteString("Conseils run_shell :\n") + for _, l := range []string{ + "Sers-t'en pour toute question système (OS, disque, process, fichiers, réseau, lire un script) plutôt que de supposer.", + "Préfère des commandes ciblées et non-interactives ; chaîne-les avec && quand elles sont dépendantes.", + "Évite les commandes destructrices (rm -rf, mkfs, dd…) sauf demande explicite de l'utilisateur.", + "Si une commande échoue, lis stderr et le code de sortie, puis corrige — ne relance pas la même commande à l'identique.", + } { + b.WriteString("- " + l + "\n") + } b.WriteString(fmt.Sprintf("Machine : hôte=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH)) if who != "" { b.WriteString(", user=" + who)