mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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:
1 parent
a453229642
commit
f632b7b42c
7 files changed
+54
-19
No files matched your search
@@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.2.7"
|
||||
const Version = "0.2.8"
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user