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).
This commit is contained in:
nathaninline committed 2026-06-25 21:26:20 +02:00
1 parent a453229642
commit f632b7b42c
7 files changed
+54 -19

No files matched your search

+5 -1
View File
@@ -67,7 +67,7 @@ func cmdChat(args []string) error {
// Print the assistant prefix once; reasoning is shown inline with a tag. // Print the assistant prefix once; reasoning is shown inline with a tag.
fmt.Print(cyan("jean") + " > ") fmt.Print(cyan("jean") + " > ")
caps := globalCaps() 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 { switch {
case ev.Err != nil: case ev.Err != nil:
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
@@ -114,6 +114,10 @@ func cmdChat(args []string) error {
} }
fmt.Println() fmt.Println()
if err == nil { 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()}) msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
} }
} }
+19 -9
View File
@@ -244,7 +244,14 @@ type streamChunk struct {
// and call /v1/chat/completions again — up to 8 iterations as a safety cap. // and call /v1/chat/completions again — up to 8 iterations as a safety cap.
const thinkClose = "</think>" const thinkClose = "</think>"
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) tools := EnabledTools(caps)
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
// in streaming mode: the model's <think> block (opened by the chat template) // in streaming mode: the model's <think> 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()) url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil { if err != nil {
return err return extra, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
authHeader(req) authHeader(req)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
cb(StreamEvent{Err: err}) cb(StreamEvent{Err: err})
return err return extra, err
} }
// A non-200 here (e.g. context window exceeded after several large tool // 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 // 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) err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
cb(StreamEvent{Err: err}) cb(StreamEvent{Err: err})
return err return extra, err
} }
toolCalls := map[int]*ToolCall{} toolCalls := map[int]*ToolCall{}
assistantContent := strings.Builder{} assistantContent := strings.Builder{}
@@ -445,7 +452,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} }
resp.Body.Close() resp.Body.Close()
if aborted { if aborted {
return nil return extra, nil
} }
// Treat any accumulated tool calls as a tool turn even if the backend set // 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 assistant.Content = s
} }
messages = append(messages, assistant) messages = append(messages, assistant)
extra = append(extra, assistant)
// Loop guard: same exact call(s) as last turn? Count it; on the 3rd // 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. // identical turn, stop so we don't churn the same command forever.
sig := strings.Builder{} sig := strings.Builder{}
@@ -487,7 +495,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
repeatSig++ repeatSig++
if repeatSig >= 2 { if repeatSig >= 2 {
cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"}) cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"})
return nil return extra, nil
} }
} else { } else {
lastSig = s lastSig = s
@@ -559,7 +567,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
shown = string(r[:4000]) + "\n…[tronqué]" shown = string(r[:4000]) + "\n…[tronqué]"
} }
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shown, Done: true}}) 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 continue
} }
@@ -569,10 +579,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if strings.TrimSpace(assistantContent.String()) == "" { if strings.TrimSpace(assistantContent.String()) == "" {
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) 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]"}) cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"})
return nil return extra, nil
} }
// healthCheck pings llama.cpp's /health endpoint. // healthCheck pings llama.cpp's /health endpoint.
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"strings" "strings"
) )
const Version = "0.2.7" const Version = "0.2.8"
func main() { func main() {
args := os.Args[1:] args := os.Args[1:]
+1 -1
View File
@@ -24,7 +24,7 @@ func cmdTest(args []string) error {
t0 := time.Now() t0 := time.Now()
var firstTok time.Time var firstTok time.Time
tokens := 0 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 ev.Content != "" {
if firstTok.IsZero() { if firstTok.IsZero() {
firstTok = time.Now() firstTok = time.Now()
+17 -3
View File
@@ -776,8 +776,15 @@ function restoreChat(){
if(!raw) return; if(!raw) return;
try{ msgs=JSON.parse(raw)||[]; }catch(e){ msgs=[]; return; } try{ msgs=JSON.parse(raw)||[]; }catch(e){ msgs=[]; return; }
for(const m of msgs){ 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); 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); 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'; document.getElementById('stop').style.display='inline-block';
addMsg('user', text); addMsg('user', text);
msgs.push({role:'user',content:text}); saveChat(); 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 tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
let serverStats=null; let serverStats=null;
const updateStats=(final)=>{ const updateStats=(final)=>{
@@ -838,6 +845,11 @@ async function send(){
const o=JSON.parse(data); const o=JSON.parse(data);
const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{};
if(d.stats){ serverStats = d.stats; } 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){ if(d.tool_used){
// Close out the current assistant/reasoning bubbles so any text the // Close out the current assistant/reasoning bubbles so any text the
// model streams *after* the tool call starts a fresh bubble BELOW it // 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(); msgs.push({role:'assistant',content:fullContent}); saveChat();
// Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités // 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 // (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){ }catch(e){
if(e.name==='AbortError'){ if(e.name==='AbortError'){
if(contentEl) setLabel(contentEl, 'assistant · '+tokCount+' tok · interrompu'); if(contentEl) setLabel(contentEl, 'assistant · '+tokCount+' tok · interrompu');
if(toolMsgs) msgs.push(...toolMsgs);
if(fullContent) msgs.push({role:'assistant',content:fullContent}); if(fullContent) msgs.push({role:'assistant',content:fullContent});
else msgs.pop(); else if(!toolMsgs) msgs.pop();
} else { } else {
addMsg('assistant','[erreur] '+e.message); msgs.pop(); addMsg('assistant','[erreur] '+e.message); msgs.pop();
} }
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 2, "Minor": 2,
"Patch": 7, "Patch": 8,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 2, "Minor": 2,
"Patch": 7, "Patch": 8,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe", "OriginalFilename": "jean.exe",
"ProductName": "Jean", "ProductName": "Jean",
"ProductVersion": "0.2.7", "ProductVersion": "0.2.8",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+8 -1
View File
@@ -630,7 +630,7 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any)
caps.Skills = *body.Skills caps.Skills = *body.Skills
} }
msgs := InjectSkills(body.Messages, caps) 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 { if ev.Err != nil {
return emit(map[string]any{"error": ev.Err.Error()}) 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 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) { func handleChat(w http.ResponseWriter, r *http.Request) {