v0.8.3 : le stop arrete vraiment, le chat ne se bloque plus, raisonnement off = off

Bug 1 (chat bloque, remonte par Nathan) : trois causes cumulees.
- runShell naissait d'un contexte independant du tour : stop n'arretait rien.
  La commande herite desormais du contexte du tour, et la boucle d'outils
  s'interrompt des que l'arret est demande.
- cmd.WaitDelay : une commande laissant un process detache gardait les tubes
  ouverts, donc Wait ne revenait JAMAIS (ni delai, ni stop).
- Reset debloque toujours l'etat de generation : « nouvelle conversation » etait
  le geste tente quand le chat coince, il ne servait a rien. Un tour perime ne
  libere plus le tour courant.
Trouve en testant : le workspace disparu cassait TOUTES les commandes suivantes
(chdir: no such file or directory) jusqu'au redemarrage ; il est recree au besoin.

Bug 2 (raisonnement, remonte par Nathan) : l'UI ecrivait « off » en EFFACANT la
ligne REASONING, or sans drapeau le moteur suit le gabarit du modele, qui
raisonne. L'UI ecrit maintenant on|off et « off » part en --reasoning off, apres
avoir demande au binaire s'il connait le drapeau (ik_llama.cpp ne le connait pas
et refuserait de demarrer). Presets existants inchanges ; l'editeur dit desormais
« non precise : le modele decide » quand la cle est absente.
This commit is contained in:
nathaninline committed 2026-08-08 11:20:22 +02:00
1 parent e6a948c314
commit acf11ff66a
13 files changed
+284 -59

No files matched your search

+57 -8
View File
@@ -1,11 +1,39 @@
package ajean
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
//
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
// l'ajoute que pour interdire le raisonnement, mieux vaut demander au binaire
// que parier : on lit son aide, une fois, au lancement du moteur.
//
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Run(); err != nil && out.Len() == 0 {
return false // aide illisible : on ne prend pas le risque
}
return strings.Contains(out.String(), "--reasoning ")
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
@@ -81,14 +109,35 @@ func cmdServe(args []string) error {
if vtv != "" {
llmArgs = append(llmArgs, "-ctv", vtv)
}
if r := cfg["REASONING"]; r != "" {
// budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au
// bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la
// réflexion atteignait le plafond et il ne restait plus de marge pour le
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
// Raisonnement. Trois cas, et la nuance compte :
//
// REASONING=on|auto|deepseek → --reasoning <valeur>
// REASONING=off → --reasoning off (interdiction EXPLICITE)
// clé absente → aucun drapeau, le moteur fait son défaut
//
// L'interface écrivait « off » en EFFAÇANT la ligne, ce qui n'est pas du tout
// la même chose : sans drapeau, llama-server suit le gabarit du modèle, et un
// modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé »
// pendant que le modèle réfléchissait quand même. Il faut le dire au moteur.
if r := strings.TrimSpace(cfg["REASONING"]); r != "" {
if reasoningActive(r) {
// budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au
// bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la
// réflexion atteignait le plafond et il ne restait plus de marge pour le
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if binSupportsReasoningFlag(bin) {
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
llmArgs = append(llmArgs, "--reasoning", "off")
} else {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
fmt.Fprintf(os.Stderr, "[ajean serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
}
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
+26 -4
View File
@@ -292,9 +292,16 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
defer func() {
c.mu.Lock()
c.Generating = false
c.cancel = nil
stale := c.epoch != epoch
// ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset
// débloque lui-même la conversation, un tour abandonné peut se terminer
// APRÈS le démarrage du suivant : remettre Generating à false ici
// déclarerait « libre » une génération toute neuve, et l'UI afficherait
// un fil qui se remplit avec un bouton « envoyer » actif.
if !stale {
c.Generating = false
c.cancel = nil
}
c.mu.Unlock()
if stale {
return // Reset pendant le tour : Reset a déjà persisté l'état vide
@@ -452,8 +459,12 @@ func (c *Conversation) CompactNow() error {
go func() {
defer func() {
c.mu.Lock()
c.Generating = false
c.cancel = nil
// Même précaution que dans generate : une compaction abandonnée par un
// Reset ne doit pas déclarer « libre » le tour qui a démarré depuis.
if c.epoch == epoch {
c.Generating = false
c.cancel = nil
}
c.mu.Unlock()
}()
if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed {
@@ -476,6 +487,15 @@ func (c *Conversation) Stop() {
// Reset démarre une nouvelle conversation (vide) pour TOUS les appareils. On
// interrompt une éventuelle génération, on vide tout et on bump epoch pour que
// les abonnés reçoivent l'ordre de nettoyer leur affichage.
//
// Reset DÉBLOQUE toujours, et c'est sa deuxième raison d'être. Il se contentait
// avant de vider le fil : si un tour restait coincé (moteur redémarré sous ses
// pieds, commande shell accrochée à ses tubes), Generating restait vrai pour
// toujours et tout message suivant se voyait refusé « génération en cours ».
// Vider le fil ne changeait rien, rafraîchir non plus : il fallait redémarrer le
// service. « Nouvelle conversation » est le geste qu'on tente naturellement dans
// ce cas ; il doit donc rendre la main, quoi qu'il arrive au tour abandonné, que
// le bump d'epoch réduit de toute façon au silence.
func (c *Conversation) Reset() {
c.Stop()
c.mu.Lock()
@@ -484,6 +504,8 @@ func (c *Conversation) Reset() {
c.Seq = 0
c.CtxUsed = 0
c.epoch++
c.Generating = false
c.cancel = nil
c.cond.Broadcast()
c.mu.Unlock()
c.persist()
+112
View File
@@ -0,0 +1,112 @@
package ajean
import (
"context"
"os"
"runtime"
"strings"
"testing"
"time"
)
// Le bouton stop doit VRAIMENT arrêter une commande shell. Elle naissait d'un
// contexte indépendant du tour : arrêter la génération ne l'arrêtait pas, et le
// tour restait suspendu jusqu'à l'expiration du délai (5 min au maximum).
func TestRunShellObeitAuStop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("commande de veille propre à Unix")
}
testHome(t)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
start := time.Now()
out := runShell(ctx, "sleep 30", 300)
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("la commande a survécu à l'annulation (%s) : %q", d, out)
}
if !strings.Contains(out, "interrompue") {
t.Errorf("résultat attendu « interrompue », reçu %q", out)
}
}
// Une commande qui laisse un process en arrière-plan garde les tubes de sortie
// ouverts. Sans WaitDelay, Wait attend leur fermeture — donc pour toujours — et
// le tour ne se terminait JAMAIS (seul un redémarrage du service débloquait).
func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("syntaxe de détachement propre à Unix")
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
t.Errorf("sortie inattendue : %q", out)
}
}
// Le dossier de travail est résolu une fois par process : s'il disparaît, plus
// AUCUNE commande ne passait (« chdir : no such file or directory ») jusqu'au
// redémarrage du service. Il doit être recréé au besoin.
func TestRunShellRecreeLeWorkspaceDisparu(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("echo se comporte différemment sous cmd.exe")
}
testHome(t)
ws := agentWorkspace()
if ws == "" {
t.Skip("pas de workspace résolu")
}
if err := os.RemoveAll(ws); err != nil {
t.Fatal(err)
}
out := runShell(context.Background(), "echo vivant", 10)
if !strings.Contains(out, "vivant") {
t.Fatalf("commande cassée par la disparition du workspace : %q", out)
}
}
// Reset doit TOUJOURS rendre la main, même sur un tour resté coincé : c'est le
// geste que l'on tente quand le chat est bloqué. Avant, Generating restait vrai
// et tout envoi suivant était refusé « génération en cours » jusqu'au
// redémarrage du service.
func TestResetDebloqueUnTourCoince(t *testing.T) {
c := newTestConv()
// Simule un tour parti et jamais terminé (commande accrochée, moteur disparu).
c.mu.Lock()
c.Generating = true
c.mu.Unlock()
c.Reset()
if st := c.state(); st["generating"] != false {
t.Fatal("après Reset, la conversation doit être déclarée libre")
}
}
// Le tour abandonné se termine parfois APRÈS le démarrage du suivant. Sa fin ne
// doit pas déclarer « libre » une génération toute neuve.
func TestTourPerimeNeLiberePasLeTourCourant(t *testing.T) {
c := newTestConv()
oldEpoch := c.epoch
c.Reset() // le tour d'epoch `oldEpoch` devient périmé
c.mu.Lock()
c.Generating = true // un nouveau tour démarre
c.mu.Unlock()
// Fin (tardive) du tour périmé : même code que le defer de generate.
c.mu.Lock()
if c.epoch == oldEpoch {
c.Generating = false
}
c.mu.Unlock()
if st := c.state(); st["generating"] != true {
t.Fatal("un tour périmé a libéré le tour courant")
}
}
+32 -4
View File
@@ -2,6 +2,7 @@ package ajean
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -135,25 +136,52 @@ func machineSystemPrompt(caps Caps) string {
// 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.
func runShell(command string, timeoutSec int) string {
//
// ⚠️ 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 {
if timeoutSec <= 0 {
timeoutSec = toolDefaultTimeout
}
if timeoutSec > toolMaxTimeout {
timeoutSec = toolMaxTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
cmd.Dir = agentWorkspace()
//
// Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le
// 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 := agentWorkspace(); 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 ajean-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
exit := 0
if err != nil {
+7 -1
View File
@@ -874,6 +874,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
extra = append(extra, assistant)
// 2. Execute each tool locally and append a "tool" reply.
for _, tc := range tcs {
// Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce
// garde, un stop pendant une série d'appels laissait défiler toute
// la série avant de reprendre la main.
if ctx.Err() != nil {
return extra, nil
}
var args map[string]any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
// Derive the human label (command / skill name) up front so we can
@@ -989,7 +995,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
result = runShell(label, to)
result = runShell(ctx, label, to)
case "web_search":
result = capWebOutput(toolWebSearch(args))
case "web_open":
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.2"
const Version = "0.8.3"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+15 -3
View File
@@ -1658,8 +1658,8 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
@@ -3158,7 +3158,19 @@ function populateSettings(){
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING')));
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
// laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement
// réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le
// sous-titre la dit. (Les presets créés ou modifiés depuis cette version
// écrivent toujours on ou off, la question ne se pose plus pour eux.)
const rz = cfgReadKey('REASONING');
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz));
const rzSub = document.getElementById('s-reasoning-sub');
if(rzSub){
rzSub.textContent = rz ? 'réflexion étape par étape'
: 'réflexion étape par étape — non précisé : le modèle décide';
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
+2 -2
View File
@@ -611,8 +611,8 @@
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
+13 -1
View File
@@ -666,7 +666,19 @@ function populateSettings(){
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING')));
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
// laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement
// réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le
// sous-titre la dit. (Les presets créés ou modifiés depuis cette version
// écrivent toujours on ou off, la question ne se pose plus pour eux.)
const rz = cfgReadKey('REASONING');
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz));
const rzSub = document.getElementById('s-reasoning-sub');
if(rzSub){
rzSub.textContent = rz ? 'réflexion étape par étape'
: 'réflexion étape par étape — non précisé : le modèle décide';
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));