From f632b7b42c1a4571f75cf3ccf13e7b53067223bd Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 25 Jun 2026 21:26:20 +0200 Subject: [PATCH] v0.2.8 : l'IA garde la trace de ses appels d'outils entre les tours Les tours d'outils (lecture de skill, commandes shell) ne survivaient qu'a l'interieur de la boucle agentique d'un seul tour : seul le texte final etait persiste. Resultat : a chaque nouveau message le modele n'avait aucune trace d'avoir deja lu un skill, donc il le re-appelait en boucle et confabulait des chemins/resultats disparus du contexte. runChat retourne desormais les messages intermediaires (assistant tool_calls + resultats tool). Le CLI les insere dans l'historique avant la reponse finale ; le serveur web les emet en fin de flux SSE pour que le client (stateless) les stocke. Couvre aussi ajean.link via E2E (handleE2EChat -> runChatStream). --- chat.go | 6 +++++- llm.go | 28 +++++++++++++++++++--------- main.go | 2 +- test_cmd.go | 2 +- ui/index.html | 20 +++++++++++++++++--- versioninfo.json | 6 +++--- web.go | 9 ++++++++- 7 files changed, 54 insertions(+), 19 deletions(-) diff --git a/chat.go b/chat.go index fc4ceff..49a0824 100644 --- a/chat.go +++ b/chat.go @@ -67,7 +67,7 @@ func cmdChat(args []string) error { // Print the assistant prefix once; reasoning is shown inline with a tag. fmt.Print(cyan("jean") + " > ") caps := globalCaps() - err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool { + extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) @@ -114,6 +114,10 @@ func cmdChat(args []string) error { } fmt.Println() if err == nil { + // Persist the tool turns (skill reads, shell runs) BEFORE the final + // answer, so next turn the model remembers it already did them instead + // of re-invoking the same skill/command from scratch. + msgs = append(msgs, extra...) msgs = append(msgs, Message{Role: "assistant", Content: full.String()}) } } diff --git a/llm.go b/llm.go index 78c5555..6dd19d2 100644 --- a/llm.go +++ b/llm.go @@ -244,7 +244,14 @@ type streamChunk struct { // and call /v1/chat/completions again — up to 8 iterations as a safety cap. const thinkClose = "" -func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) error { +// runChat returns `extra`: the tool-turn messages (assistant-with-tool_calls and +// their tool results) it appended during the agentic loop. The caller persists +// these into the durable history BEFORE the final assistant text, so the model +// keeps the trace of what it already read/ran across user turns — otherwise it +// re-invokes the same skill/command every turn (it has no memory of having done +// it) and can confabulate paths/results it can no longer see. +func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]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) @@ -280,14 +287,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { - return err + return extra, err } req.Header.Set("Content-Type", "application/json") authHeader(req) resp, err := http.DefaultClient.Do(req) if err != nil { cb(StreamEvent{Err: err}) - return err + return extra, err } // A non-200 here (e.g. context window exceeded after several large tool // outputs) is NOT valid SSE: without this check we'd scan an empty/HTML @@ -313,7 +320,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg) cb(StreamEvent{Err: err}) - return err + return extra, err } toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} @@ -445,7 +452,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } resp.Body.Close() if aborted { - return nil + return extra, nil } // Treat any accumulated tool calls as a tool turn even if the backend set @@ -474,6 +481,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps assistant.Content = s } messages = append(messages, assistant) + extra = append(extra, 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{} @@ -487,7 +495,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps repeatSig++ if repeatSig >= 2 { cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"}) - return nil + return extra, nil } } else { lastSig = s @@ -559,7 +567,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps shown = string(r[:4000]) + "\n…[tronqué]" } cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shown, Done: true}}) - messages = append(messages, Message{Role: "tool", ToolCallID: tc.ID, Content: result}) + toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} + messages = append(messages, toolMsg) + extra = append(extra, toolMsg) } continue } @@ -569,10 +579,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if strings.TrimSpace(assistantContent.String()) == "" { cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) } - return nil + return extra, nil } cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"}) - return nil + return extra, nil } // healthCheck pings llama.cpp's /health endpoint. diff --git a/main.go b/main.go index 8b97366..11c966a 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( "strings" ) -const Version = "0.2.7" +const Version = "0.2.8" func main() { args := os.Args[1:] diff --git a/test_cmd.go b/test_cmd.go index a523d28..887ba58 100644 --- a/test_cmd.go +++ b/test_cmd.go @@ -24,7 +24,7 @@ func cmdTest(args []string) error { t0 := time.Now() var firstTok time.Time tokens := 0 - err := runChat(context.Background(), msgs, 0, Caps{}, func(ev StreamEvent) bool { + _, err := runChat(context.Background(), msgs, 0, Caps{}, func(ev StreamEvent) bool { if ev.Content != "" { if firstTok.IsZero() { firstTok = time.Now() diff --git a/ui/index.html b/ui/index.html index 914859e..a7ead42 100644 --- a/ui/index.html +++ b/ui/index.html @@ -776,8 +776,15 @@ function restoreChat(){ if(!raw) return; try{ msgs=JSON.parse(raw)||[]; }catch(e){ msgs=[]; return; } for(const m of msgs){ + // Les messages d'outil (role 'tool' et assistant porteur de tool_calls sans + // texte) font partie de l'historique envoyé au modèle mais ne s'affichent pas : + // on saute pour ne pas créer de bulles vides au rechargement. + if(m.role==='tool') continue; if(m.role==='user') addMsg('user', m.content); - else if(m.role==='assistant'){ const el=addMsg('assistant',''); renderBody(el, m.content||''); } + else if(m.role==='assistant'){ + if(!m.content && m.tool_calls) continue; + const el=addMsg('assistant',''); renderBody(el, m.content||''); + } } const cu=parseInt(localStorage.getItem('jean.ctxused')||'0',10); if(cu>0) setCtxUsed(cu); } @@ -793,7 +800,7 @@ async function send(){ document.getElementById('stop').style.display='inline-block'; addMsg('user', text); msgs.push({role:'user',content:text}); saveChat(); - let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason=''; + let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null; let tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null; let serverStats=null; const updateStats=(final)=>{ @@ -838,6 +845,11 @@ async function send(){ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; if(d.stats){ serverStats = d.stats; } + // Tool-turn messages (assistant tool_calls + tool results) sent at the + // end of the stream. On les garde pour les réinjecter dans msgs AVANT + // la réponse finale, sinon le modèle re-déclenche le même skill/commande + // à chaque tour (aucune trace de l'avoir déjà fait). + if(d.tool_messages){ toolMsgs = d.tool_messages; } if(d.tool_used){ // Close out the current assistant/reasoning bubbles so any text the // model streams *after* the tool call starts a fresh bubble BELOW it @@ -873,6 +885,7 @@ async function send(){ } } } + if(toolMsgs) msgs.push(...toolMsgs); msgs.push({role:'assistant',content:fullContent}); saveChat(); // Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités // (prompt_tokens, le préfixe caché n'est pas recompté) + ceux générés. La @@ -881,8 +894,9 @@ async function send(){ }catch(e){ if(e.name==='AbortError'){ if(contentEl) setLabel(contentEl, 'assistant · '+tokCount+' tok · interrompu'); + if(toolMsgs) msgs.push(...toolMsgs); if(fullContent) msgs.push({role:'assistant',content:fullContent}); - else msgs.pop(); + else if(!toolMsgs) msgs.pop(); } else { addMsg('assistant','[erreur] '+e.message); msgs.pop(); } diff --git a/versioninfo.json b/versioninfo.json index ad26496..83e1c6a 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 2, - "Patch": 7, + "Patch": 8, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 2, - "Patch": 7, + "Patch": 8, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.2.7", + "ProductVersion": "0.2.8", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/web.go b/web.go index 0b04de9..25060ba 100644 --- a/web.go +++ b/web.go @@ -630,7 +630,7 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) caps.Skills = *body.Skills } msgs := InjectSkills(body.Messages, caps) - _ = runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool { + extra, _ := runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool { if ev.Err != nil { return emit(map[string]any{"error": ev.Err.Error()}) } @@ -648,6 +648,13 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) } return true }) + // Surface the tool-turn messages (assistant tool_calls + tool results) as a + // final event so the stateless web client can store them in its history, + // BEFORE the final assistant text. Without this the browser only keeps the + // final answer and the model re-invokes the same skill/command every turn. + if len(extra) > 0 { + emit(map[string]any{"tool_messages": extra}) + } } func handleChat(w http.ResponseWriter, r *http.Request) {