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.
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()})
}
}
+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.
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)
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
// 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())
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.
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"strings"
)
const Version = "0.2.7"
const Version = "0.2.8"
func main() {
args := os.Args[1:]
+1 -1
View File
@@ -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()
+17 -3
View File
@@ -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();
}
+3 -3
View File
@@ -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": {
+8 -1
View File
@@ -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) {