diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 51907ff..7162595 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -489,9 +489,13 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // répondre le modèle une seconde fois à une vieille question ; // - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se // retrouvait en double, cette fois APRÈS ses propres résultats d'outils. + // + // Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une + // demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni + // il ne se réinjecte à la place de la vraie. tailHasUser := false for _, m := range msgs[tailStart:] { - if m.Role == "user" { + if m.Role == "user" && !isLokiInjected(m) { tailHasUser = true break } @@ -499,7 +503,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, summarized := err == nil && !summaryLooksEmpty(summary) var pending []Message for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- { - if torso[i].Role != "user" { + if torso[i].Role != "user" || isLokiInjected(torso[i]) { continue } if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) { @@ -620,6 +624,12 @@ func renderTranscript(msgs []Message) string { for _, m := range msgs { switch m.Role { case "user": + // Rappel de loki : une note du système, pas une demande que le + // résumé devrait attribuer à l'utilisateur. + if isLokiInjected(m) { + fmt.Fprintf(&b, "System: %s\n", msgText(m)) + continue + } fmt.Fprintf(&b, "User: %s\n", msgText(m)) case "assistant": if t := msgText(m); t != "" { @@ -904,12 +914,26 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) { head++ } for estimateTokens(out) > target { - next := -1 + // Coupe d'abord aux vraies demandes : couper sur un rappel de loki + // jetterait la demande en gardant la fin de sa boucle d'outils. Un + // rappel ne sert de coupe qu'en dernier recours, quand il n'y a plus + // aucune demande après la tête (une seule longue boucle d'outils) — + // comme avant qu'ils soient persistés. + next, nudge := -1, -1 for k := head + 1; k < len(out); k++ { - if out[k].Role == "user" { + if out[k].Role != "user" { + continue + } + if !isLokiInjected(out[k]) { next = k break } + if nudge < 0 { + nudge = k + } + } + if next < 0 { + next = nudge } if next < 0 { break diff --git a/internal/loki/chat_export.go b/internal/loki/chat_export.go index a4fd48f..70444d3 100644 --- a/internal/loki/chat_export.go +++ b/internal/loki/chat_export.go @@ -221,7 +221,8 @@ func filterLog(log []LogEvent, o exportOpts) []LogEvent { func lastTurnsMessages(msgs []Message, n int) []Message { seen := 0 for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i].Role != "user" { + // Un rappel de loki n'ouvre pas un échange. + if msgs[i].Role != "user" || isLokiInjected(msgs[i]) { continue } seen++ diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index 0bd7878..4e13fc9 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -169,7 +169,7 @@ func convTouchMeta(id string, title string, turns int) { // posera tout seul au premier échange. func convSummary(msgs []Message) string { for _, m := range msgs { - if m.Role != "user" { + if m.Role != "user" || isLokiInjected(m) { continue } s, _ := m.Content.(string) diff --git a/internal/loki/code_retry.go b/internal/loki/code_retry.go index a094a73..c62c5a8 100644 --- a/internal/loki/code_retry.go +++ b/internal/loki/code_retry.go @@ -106,7 +106,7 @@ const maxPatternRetries = 3 // retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait // fautif. func retryCorrective(snippet string) string { - return "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + snippet + + return retryCorrectiveLead + snippet + "\nTool calls must go through the tool-call protocol, never in the answer text. " + "Redo it now: emit the real tool call, or answer directly without pretending to call a tool." } diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 71c1d1b..3bfc854 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -266,7 +266,8 @@ func (c *Conversation) lastUserText() string { c.mu.Lock() defer c.mu.Unlock() for i := len(c.Messages) - 1; i >= 0; i-- { - if c.Messages[i].Role != "user" { + // Un rappel de loki (budget, relance) n'est pas la tâche. + if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) { continue } if s, ok := c.Messages[i].Content.(string); ok { diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 4ac0bc2..6510bcb 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -962,6 +962,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // retry the same turn once with tools removed so the model answers in plain // text from the tool results already gathered, instead of dying mid-chat. disableTools := false + // Première relance après ce 500, sur le llama-server local : outils GARDÉS + // à l'identique dans la requête, mais tool_choice « none » et la consigne en + // fin de fil (withTrailingHint). Le prompt rendu ne change pas jusqu'au + // dernier message, donc le cache sert presque tout ; retirer les outils + // changeait le gabarit dès le système et recalculait toute la conversation. + // Les appels éventuels sont ignorés (callsOn) ; le moindre écart — nouveau + // refus, appel émis malgré tout, réponse vide — retombe UNE fois sur le + // chemin historique (outils retirés, consigne via steerSystem). + toolChoiceNone := false // Filet réactif (façon Hermes) : si llama-server refuse le prompt (souvent un // dépassement de la fenêtre de contexte après de gros résultats d'outils), on // compacte l'historique en vol et on rejoue le tour — une seule fois. @@ -1045,17 +1054,25 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } } // Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté - // llama-server reste valide, seul le nouveau message est à traiter. + // llama-server reste valide, seul le nouveau message est à traiter. Et + // persisté tel quel (appendNudge) : le tour suivant le renvoie à + // l'identique au lieu de diverger juste avant lui. if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { budgetNudges++ logBudget(toolRuns, budget, budgetNudges) - messages = append(messages, Message{Role: "user", Content: msg}) + messages, extra = appendNudge(messages, extra, msg) } + // Appels d'outils exécutables pour cette complétion : outils annoncés, + // et ni coupés ni neutralisés par tool_choice « none ». + callsOn := len(tools) > 0 && !disableTools && !toolChoiceNone // Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits // stricts (Qwen3.x) refusent un système ailleurs qu'en position 0. // Gardé à part : la télémétrie compare ces messages-là d'une requête à // l'autre (perfPrefix). sent := normalizeSystemMessages(messages) + if toolChoiceNone { + sent = withTrailingHint(sent, toolsOffHint) + } payload := map[string]any{ "model": ep.Model, // Les images de l'historique y sont rangées par référence @@ -1089,6 +1106,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a // single tool call per turn avoids that. payload["parallel_tool_calls"] = false + if toolChoiceNone { + payload["tool_choice"] = "none" + } } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) @@ -1194,12 +1214,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // in plain text rather than leaving the chat dead. Seulement sur 500 : // un 401, 429 ou 502 (API externe, passerelle) n'a rien à voir avec un // appel mal formé, et couper les outils pour ça cassait le tour d'agent. - if resp.StatusCode == http.StatusInternalServerError && !disableTools && len(tools) > 0 { + // + // Sur le llama-server local, la première relance garde le prompt + // intact (voir toolChoiceNone). Une API externe garde le chemin + // historique : certaines passerelles OpenAI-compatibles traitent + // tool_choice à leur façon. Si la relance « none » est refusée à + // son tour (500, ou un 4xx d'un moteur qui ne connaîtrait pas le + // champ), on retombe sur le chemin historique au lieu de finir le + // tour. + if !disableTools && len(tools) > 0 && (resp.StatusCode == http.StatusInternalServerError || + (toolChoiceNone && resp.StatusCode >= 400 && resp.StatusCode < 500)) { + if !ep.External && !toolChoiceNone { + toolChoiceNone = true + logCtx("relance sans outil après un 500 : tool_choice=none, prompt conservé") + continue + } + toolChoiceNone = false disableTools = true // Nudge the model to answer in plain text from what it already // gathered, so it doesn't immediately re-emit a tool call that // llama.cpp would again fail to parse. - messages = steerSystem(messages, "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.") + messages = steerSystem(messages, toolsOffHint) continue } // Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut @@ -1279,6 +1314,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps aborted := false // patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas). patternHit := false + // noneLeak : sous tool_choice « none », le modèle a quand même tenté un + // appel (protocole ou texte) — voir toolChoiceNone. + noneLeak := false // preflight : write/edit refusé dès son chemin (voir plus bas) ; non-nil // mais vide = chemin déjà vérifié, rien à signaler. var preflight *preflightRefusal @@ -1349,7 +1387,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // relance sans outils après un 500 — le moteur peut quand même parser un // appel que le modèle a émis de lui-même ; l'exécuter donnait « outil // inconnu », réinjecté puis mis en boucle. On l'ignore (AJEAN 0.13.12). - if len(tools) > 0 && !disableTools && len(ch.Delta.ToolCalls) > 0 { + // Relance tool_choice « none » : un appel émis malgré tout n'est pas + // exécuté, on coupe et on retombe sur la relance sans outils. + if toolChoiceNone && len(ch.Delta.ToolCalls) > 0 { + noneLeak = true + break + } + if callsOn && len(ch.Delta.ToolCalls) > 0 { // Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat // retenu par la garde « » (voir plus bas). Sinon il n'était // émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI @@ -1481,9 +1525,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // faux appel — parfois un fichier entier — qui ne sera jamais // exécuté. La relance corrective part juste après (voir fin de // boucle). Testé seulement quand le morceau peut ouvrir un motif. - if len(tools) > 0 && !disableTools && patternRetries < maxPatternRetries && + if (callsOn && patternRetries < maxPatternRetries || toolChoiceNone) && strings.ContainsAny(ch.Delta.Content, "<`{[_.") && textualToolCall(assistantContent.String()) { - patternHit = true + // Sous tool_choice « none », le moteur ne parse plus les + // appels : le balisage arrive en texte. Ce n'est pas une + // réponse, on retombe sur la relance sans outils. + if toolChoiceNone { + noneLeak = true + } else { + patternHit = true + } break } } else { @@ -1614,7 +1665,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est // complète, seule la fermeture a raté. On la garde telle quelle. Flux // coupé par NOUS sur un appel écrit en texte : pas une panne non plus. - if scanErr != nil && (finishReason != "" || patternHit || (preflight != nil && preflight.msg != "")) { + if scanErr != nil && (finishReason != "" || patternHit || noneLeak || (preflight != nil && preflight.msg != "")) { scanErr = nil } // Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on @@ -1659,6 +1710,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // rapprochée, pas pour tout un long tour d'agent. streamRetries = 0 + // Relance tool_choice « none » sans réponse exploitable (appel tenté + // malgré tout, balisage d'appel en texte, ou rien du tout) : repli UNE + // fois sur le chemin historique, outils retirés du gabarit. Le prompt est + // alors recalculé, mais le tour aboutit comme avant. + if toolChoiceNone && (noneLeak || textualToolCallSnippet(assistantContent.String()) != "" || + strings.TrimSpace(assistantContent.String()) == "") { + logCtx("relance tool_choice=none sans réponse exploitable : repli sans outils") + toolChoiceNone = false + disableTools = true + if sentReasoning && !sentAnswer { + cb(StreamEvent{DropReasoning: true}) + } + messages = steerSystem(messages, toolsOffHint) + continue + } + // Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son chemin, // entre dans l'historique avec le refus pour résultat — le modèle voit // pourquoi, et la boucle repart pour qu'il lise le fichier d'abord. @@ -2055,7 +2122,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // mentir sur ce qui s'est passé — mais l'historique du modèle garde la // trace ET la correction, donc la vraie réponse suit immédiatement. if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" && - patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools { + patternRetries < maxPatternRetries && callsOn { patternRetries++ bad := Message{Role: "assistant", Content: assistantContent.String()} fix := Message{Role: "user", Content: retryCorrective(snippet)} @@ -2092,21 +2159,24 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps continue } } - if len(tools) > 0 && !disableTools && nudgeCount < maxNudges { + if callsOn && nudgeCount < maxNudges { nudgeCount++ logCtx("relance « pensé sans agir » n°%d (finish=%s)", nudgeCount, finishReason) // Le raisonnement de ce tour avorté ne mène à rien : on demande à // l'UI de l'effacer avant de relancer, pour ne pas afficher deux // blocs de réflexion successifs. cb(StreamEvent{DropReasoning: true}) - nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act." + nudge := thinkNudgeFirst if nudgeCount > 1 { // Le premier nudge n'a pas suffi : le modèle re-décrit le même // plan sans l'exécuter. Second nudge plus impératif, où on lui // interdit explicitement de re-raisonner. - nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant." + nudge = thinkNudgeStuck } - messages = append(messages, Message{Role: "user", Content: nudge}) + // Persisté tel qu'envoyé (appendNudge) : au tour suivant, le modèle + // relit la consigne qui a produit sa réponse, et le préfixe en + // cache tient jusqu'à elle. + messages, extra = appendNudge(messages, extra, nudge) continue } cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) diff --git a/internal/loki/llm_injected.go b/internal/loki/llm_injected.go new file mode 100644 index 0000000..9aedae1 --- /dev/null +++ b/internal/loki/llm_injected.go @@ -0,0 +1,107 @@ +package loki + +// llm_injected.go — messages que LOKI glisse dans le fil (rappels, relances), +// et leur place dans l'historique. +// +// Ces rappels partaient autrefois dans la vue du modèle SANS entrer dans +// l'historique persistant : au tour suivant, llama-server recevait un fil où ils +// avaient disparu, le préfixe divergeait juste avant eux et tout ce qui suivait +// (souvent une longue boucle d'outils) était recalculé. Le modèle relisait en +// prime une histoire qu'il n'avait jamais vue : des résultats d'outils suivis +// d'une réponse finale, sans la consigne qui l'avait provoquée. +// +// Persistés tels qu'envoyés, ils ont un rôle `user` sans être des demandes de +// l'utilisateur : tout ce qui lit « le dernier message user » comme la tâche en +// cours (compaction, vérificateur, export, titre) doit donc les reconnaître et +// les sauter — c'est isLokiInjected. + +import "strings" + +// lokiNotePrefix ouvre les rappels de loki (budget d'outils, consigne de la +// relance après un 500) : une marque stable, lisible par le modèle comme une +// note du système et non comme la parole de l'utilisateur. +const lokiNotePrefix = "[system] " + +// Relances « pensé sans agir » (voir runChat). Gardées en constantes : leur +// texte exact sert aussi à les reconnaître dans l'historique. +const ( + thinkNudgeFirst = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act." + thinkNudgeStuck = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant." +) + +// retryCorrectiveLead ouvre la consigne de relance « appel écrit en texte » +// (code_retry.go), déjà persistée avant ce fichier. +const retryCorrectiveLead = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + +// toolsOffHint : consigne de la relance après un 500 (appel d'outil que +// llama.cpp n'a pas su parser). Jamais persistée : elle ne vaut que pour la +// relance qui suit. +const toolsOffHint = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered." + +// isLokiInjected dit si un message `user` vient de loki et non de +// l'utilisateur. +func isLokiInjected(m Message) bool { + if m.Role != "user" { + return false + } + s, ok := m.Content.(string) + if !ok { + return false + } + return strings.HasPrefix(s, lokiNotePrefix) || s == thinkNudgeFirst || s == thinkNudgeStuck || + strings.HasPrefix(s, retryCorrectiveLead) +} + +// appendNudge ajoute un rappel de loki à la vue du modèle et, quand c'est sans +// risque, à l'historique persistant — exactement tel qu'envoyé, pour que le +// tour suivant rejoue les mêmes octets et retrouve son préfixe en cache. +// +// Sauf s'il suit directement un autre message `user` (le message de +// l'utilisateur à l'étape 0, un autre rappel, un ajout en cours de réponse) : +// les gabarits qui exigent l'alternance stricte user/assistant (Gemma, +// Mistral) lèvent une exception sur deux `user` consécutifs. Éphémère, cette +// paire ne coûte qu'une étape ; persistée, elle ferait échouer chaque tour +// suivant de la conversation. Le rappel reste alors éphémère, comme avant. +func appendNudge(messages, extra []Message, text string) ([]Message, []Message) { + m := Message{Role: "user", Content: text} + afterUser := len(messages) > 0 && messages[len(messages)-1].Role == "user" + messages = append(messages, m) + if !afterUser { + extra = append(extra, m) + } + return messages, extra +} + +// withTrailingHint renvoie une COPIE de msgs dont le dernier message porte en +// plus la consigne hint, à la fin. Sert à la relance après un 500 : poser la +// consigne en tête (steerSystem) modifiait le message système, donc tout le +// prompt était à recalculer ; à la fin, le préfixe déjà en cache reste valide. +// +// Ajoutée au contenu du dernier message (résultat d'outil ou message user) +// plutôt qu'en nouveau message : deux `user` d'affilée cassent les gabarits +// stricts, et sur Qwen3.5 un nouveau message user devient la « dernière +// question » et fait re-rendre toute la boucle du tour. Les messages ne sont +// jamais modifiés en place : ils appartiennent à l'historique. +func withTrailingHint(msgs []Message, hint string) []Message { + note := "\n\n" + lokiNotePrefix + hint + out := append([]Message(nil), msgs...) + if n := len(out); n > 0 && (out[n-1].Role == "tool" || out[n-1].Role == "user") { + last := out[n-1] + switch c := last.Content.(type) { + case string: + last.Content = c + note + out[n-1] = last + return out + case []any: + last.Content = append(append([]any(nil), c...), map[string]any{"type": "text", "text": note}) + out[n-1] = last + return out + case []map[string]any: + last.Content = append(append([]map[string]any(nil), c...), map[string]any{"type": "text", "text": note}) + out[n-1] = last + return out + } + } + // Dernier message d'une autre forme (cas théorique) : un message à part. + return append(out, Message{Role: "user", Content: lokiNotePrefix + hint}) +} diff --git a/internal/loki/llm_injected_test.go b/internal/loki/llm_injected_test.go new file mode 100644 index 0000000..57acda5 --- /dev/null +++ b/internal/loki/llm_injected_test.go @@ -0,0 +1,408 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "testing" +) + +const sseStop = `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n" + +const sseGlobCall = `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n" + +// scriptedServer répond à la n-ième requête par steps[n] (le dernier se répète) +// et garde le corps JSON de chaque requête. Un step "500" renvoie une erreur +// 500, comme llama.cpp sur un appel d'outil qu'il ne sait pas parser. +func scriptedServer(t *testing.T, steps ...string) func() []map[string]any { + t.Helper() + var mu sync.Mutex + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies = append(bodies, body) + n := len(bodies) - 1 + mu.Unlock() + step := steps[min(n, len(steps)-1)] + if step == "500" { + http.Error(w, "Failed to parse tool call", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(step)) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + return func() []map[string]any { + mu.Lock() + defer mu.Unlock() + return append([]map[string]any(nil), bodies...) + } +} + +// reqMessages relit les messages d'une requête enregistrée. +func reqMessages(t *testing.T, body map[string]any) []Message { + t.Helper() + raw, _ := json.Marshal(body["messages"]) + var out []Message + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestIsLokiInjected(t *testing.T) { + cases := []struct { + name string + m Message + want bool + }{ + {"budget 1", um(budgetNudge(24, 24, 0)), true}, + {"budget 2", um(budgetNudge(48, 24, 1)), true}, + {"budget 3", um(budgetNudge(72, 24, 2)), true}, + {"pensé sans agir", um(thinkNudgeFirst), true}, + {"pensé sans agir, bis", um(thinkNudgeStuck), true}, + {"appel écrit en texte", um(retryCorrective("x")), true}, + {"vraie demande", um("cherche les horaires"), false}, + {"demande qui cite un rappel", um("pourquoi « " + thinkNudgeFirst + " » ?"), false}, + {"assistant", am(lokiNotePrefix + "x"), false}, + {"user multimodal", Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": lokiNotePrefix}}}, false}, + } + for _, c := range cases { + if got := isLokiInjected(c.m); got != c.want { + t.Errorf("%s : isLokiInjected = %v, attendu %v", c.name, got, c.want) + } + } +} + +// Le rappel entre dans l'historique tel qu'envoyé, sauf derrière un autre +// message user (gabarits à alternance stricte) où il reste éphémère. +func TestAppendNudge(t *testing.T) { + cases := []struct { + name string + before []Message + persisted bool + }{ + {"après un résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, true}, + {"après un assistant", []Message{um("q"), am("a")}, true}, + {"après la demande", []Message{um("q")}, false}, + {"après un autre rappel", []Message{um("q"), atc("glob"), tm("r"), um(thinkNudgeFirst)}, false}, + {"fil vide", nil, true}, + } + for _, c := range cases { + msgs, extra := appendNudge(append([]Message(nil), c.before...), nil, thinkNudgeFirst) + if len(msgs) != len(c.before)+1 || msgText(msgs[len(msgs)-1]) != thinkNudgeFirst { + t.Errorf("%s : rappel absent de la vue du modèle : %+v", c.name, msgs) + } + if got := len(extra) == 1; got != c.persisted { + t.Errorf("%s : persisté = %v, attendu %v", c.name, got, c.persisted) + } + if c.persisted && !reflect.DeepEqual(extra[0], msgs[len(msgs)-1]) { + t.Errorf("%s : persisté %+v ≠ envoyé %+v", c.name, extra[0], msgs[len(msgs)-1]) + } + } +} + +func TestWithTrailingHint(t *testing.T) { + note := "\n\n" + lokiNotePrefix + "H" + cases := []struct { + name string + in []Message + want Message // dernier message attendu + grow bool // un message ajouté plutôt qu'enrichi + }{ + {"résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, Message{Role: "tool", ToolCallID: "c1", Content: "r" + note}, false}, + {"message user", []Message{um("q")}, um("q" + note), false}, + {"parties []any", []Message{{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}}}}, + Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}, map[string]any{"type": "text", "text": note}}}, false}, + {"parties []map", []Message{{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}}}}, + Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}, {"type": "text", "text": note}}}, false}, + {"assistant en dernier", []Message{um("q"), am("a")}, um(lokiNotePrefix + "H"), true}, + } + for _, c := range cases { + orig, _ := json.Marshal(c.in) + out := withTrailingHint(c.in, "H") + if after, _ := json.Marshal(c.in); string(after) != string(orig) { + t.Errorf("%s : l'historique d'origine a été modifié", c.name) + } + wantLen := len(c.in) + if c.grow { + wantLen++ + } + if len(out) != wantLen { + t.Fatalf("%s : %d messages, attendu %d", c.name, len(out), wantLen) + } + if !reflect.DeepEqual(out[len(out)-1], c.want) { + t.Errorf("%s : dernier message %+v, attendu %+v", c.name, out[len(out)-1], c.want) + } + if !reflect.DeepEqual(out[:len(c.in)-1], c.in[:len(c.in)-1]) { + t.Errorf("%s : le début du fil a changé", c.name) + } + } +} + +// Les lecteurs de « la demande de l'utilisateur » sautent les rappels. +func TestRappelsPasPrisPourLaDemande(t *testing.T) { + msgs := []Message{um("corrige le build"), atc("bash"), tm("ok"), um(budgetNudge(24, 24, 0)), am("fait"), + um("et les tests"), atc("bash"), tm("ok"), um(thinkNudgeFirst), am("fait")} + c := &Conversation{Messages: msgs} + if got := c.lastUserText(); got != "et les tests" { + t.Errorf("lastUserText = %q", got) + } + if got := lastTurnsMessages(msgs, 1); len(got) == 0 || msgText(got[0]) != "et les tests" { + t.Errorf("dernier échange = %+v", got) + } + if got := lastTurnsMessages(msgs, 2); len(got) != len(msgs) { + t.Errorf("deux échanges : %d messages, attendu %d", len(got), len(msgs)) + } + if got := convSummary([]Message{um(thinkNudgeFirst), um("bonjour")}); got != "bonjour" { + t.Errorf("titre = %q", got) + } + if tr := renderTranscript([]Message{um(thinkNudgeFirst)}); !strings.HasPrefix(tr, "System: ") { + t.Errorf("rappel présenté au résumeur comme parole de l'utilisateur : %q", tr) + } +} + +// Compaction après un long tour ponctué de rappels : la VRAIE demande est +// réinjectée, qu'un rappel traîne dans le torse ou dans la queue. +func TestCompactionReinjecteLaVraieDemandePasLeRappel(t *testing.T) { + testHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- pages lues : horaires trouvés pour le matin, reste l'après-midi"}}}}) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + page := func(n int) Message { + return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400)) + } + torsoNudge, tailNudge := budgetNudge(24, 24, 0), budgetNudge(48, 24, 1) + msgs := []Message{um("première question"), am("ok"), um("cherche les horaires du train pour Lyon")} + for i := 0; i < 12; i++ { + msgs = append(msgs, atc("web_read"), page(i)) + if i == 3 { + msgs = append(msgs, um(torsoNudge)) + } + } + msgs = append(msgs, um(tailNudge), atc("web_read"), page(13)) + _, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac)) + if msgText(msgs[len(msgs)-3]) != tailNudge || tailStart > len(msgs)-3 { + t.Fatalf("montage : le rappel de queue n'est pas dans la queue (tailStart=%d)", tailStart) + } + out, changed := compactMessages(t.Context(), msgs, Caps{}) + if !changed { + t.Fatal("pas compacté") + } + count := func(s string) int { + n := 0 + for _, m := range out { + if m.Role == "user" && msgText(m) == s { + n++ + } + } + return n + } + if n := count("cherche les horaires du train pour Lyon"); n != 1 { + t.Fatalf("demande en cours présente %d fois après compaction, attendu 1", n) + } + if count(torsoNudge) != 0 { + t.Fatal("le rappel du torse a été réinjecté comme demande en cours") + } + if count(tailNudge) != 1 { + t.Fatal("le rappel de la queue a disparu") + } +} + +// La réduction forcée coupe aux vraies demandes ; un rappel ne sert de coupe +// qu'à défaut. +func TestShrinkCoupeAuxVraiesDemandes(t *testing.T) { + testHome(t) + if err := SetConfigKey("CTX", "4096"); err != nil { + t.Fatal(err) + } + big := am(strings.Repeat("raisonnement long ", 400)) + sys := Message{Role: "system", Content: "SYS"} + out, changed := shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big, um("demande B"), am("ok")}, 0) + if !changed || len(out) < 2 || msgText(out[1]) != "demande B" { + t.Fatalf("coupe attendue à « demande B » : %+v", out) + } + out, changed = shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big}, 0) + if !changed || len(out) < 2 || msgText(out[1]) != thinkNudgeFirst { + t.Fatalf("sans autre demande, la coupe doit retomber sur le rappel : %+v", out) + } +} + +// Le rappel de budget part au moteur ET dans l'historique renvoyé : le tour +// suivant rejoue les mêmes octets. +func TestRappelDeBudgetPersisteTelQuEnvoye(t *testing.T) { + withWorkspace(t) + if err := SetConfigKey("AGENT_BUDGET", "1"); err != nil { + t.Fatal(err) + } + reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop) + extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + bodies := reqs() + if len(bodies) != 2 { + t.Fatalf("%d requêtes, attendu 2", len(bodies)) + } + sent := reqMessages(t, bodies[1]) + last := sent[len(sent)-1] + if !strings.HasPrefix(msgText(last), lokiNotePrefix) { + t.Fatalf("rappel absent de la requête : %+v", last) + } + if len(extra) != 3 || !reflect.DeepEqual(extra[2], last) { + t.Fatalf("historique renvoyé %+v : le rappel doit y être tel qu'envoyé", extra) + } +} + +// « Pensé sans agir » : persisté après un résultat d'outil, éphémère juste +// après le message de l'utilisateur (deux user d'affilée). +func TestRelancePenseSansAgirPersistee(t *testing.T) { + withWorkspace(t) + reqs := scriptedServer(t, sseGlobCall, sseStop, sseChunk("fini")+sseStop) + extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + if n := len(reqs()); n != 3 { + t.Fatalf("%d requêtes, attendu 3", n) + } + if len(extra) != 3 || extra[2].Role != "user" || msgText(extra[2]) != thinkNudgeFirst { + t.Fatalf("relance non persistée après l'outil : %+v", extra) + } + + reqs = scriptedServer(t, sseStop, sseChunk("fini")+sseStop) + extra, err = runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + if bodies := reqs(); len(bodies) != 2 || msgText(reqMessages(t, bodies[1])[1]) != thinkNudgeFirst { + t.Fatal("la relance doit quand même partir au moteur") + } + if len(extra) != 0 { + t.Fatalf("relance persistée derrière la demande (deux user d'affilée) : %+v", extra) + } +} + +// Relance après un 500 (llama-server local) : mêmes outils, même message 0, +// tool_choice none et la consigne au bout du dernier message — qui n'entre +// pas dans l'historique. +func TestRelance500GardeLePrompt(t *testing.T) { + testHome(t) + reqs := scriptedServer(t, "500", sseChunk("voici")+sseStop) + in := []Message{{Role: "system", Content: "SYS"}, um("question")} + extra, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + bodies := reqs() + if len(bodies) != 2 { + t.Fatalf("%d requêtes, attendu 2", len(bodies)) + } + first, retry := bodies[0], bodies[1] + if first["tools"] == nil || !reflect.DeepEqual(first["tools"], retry["tools"]) { + t.Fatal("les outils doivent rester identiques dans la relance") + } + if _, ok := first["tool_choice"]; ok { + t.Fatal("tool_choice posé hors relance") + } + if retry["tool_choice"] != "none" { + t.Fatalf("tool_choice = %v, attendu none", retry["tool_choice"]) + } + m0, r0 := reqMessages(t, first), reqMessages(t, retry) + if !reflect.DeepEqual(m0[0], r0[0]) { + t.Fatalf("message 0 modifié : %+v → %+v", m0[0], r0[0]) + } + if got := msgText(r0[len(r0)-1]); got != "question\n\n"+lokiNotePrefix+toolsOffHint { + t.Fatalf("consigne absente du bout du fil : %q", got) + } + if len(extra) != 0 { + t.Fatalf("la consigne ne doit pas être persistée : %+v", extra) + } +} + +// Repli : la relance « none » échoue à son tour (500, ou appel tenté malgré +// tout) → chemin historique, outils retirés et consigne dans le système. +func TestRelance500ReplieSurLeCheminHistorique(t *testing.T) { + leak := `data: {"choices":[{"delta":{"content":""}}]}` + "\n\n" + sseStop + for name, second := range map[string]string{"second 500": "500", "appel en texte": leak, "réponse vide": sseStop} { + t.Run(name, func(t *testing.T) { + testHome(t) + reqs := scriptedServer(t, "500", second, sseChunk("voici")+sseStop) + in := []Message{{Role: "system", Content: "SYS"}, um("question")} + if _, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + bodies := reqs() + if len(bodies) != 3 { + t.Fatalf("%d requêtes, attendu 3", len(bodies)) + } + last := bodies[2] + if _, ok := last["tools"]; ok { + t.Fatal("le repli doit retirer les outils") + } + if _, ok := last["tool_choice"]; ok { + t.Fatal("tool_choice ne doit pas survivre au repli") + } + msgs := reqMessages(t, last) + if !strings.Contains(msgText(msgs[0]), toolsOffHint) || strings.Contains(msgText(msgs[len(msgs)-1]), toolsOffHint) { + t.Fatalf("consigne du repli mal placée : %+v", msgs) + } + }) + } +} + +// Preset externe : la relance garde le chemin historique dès le premier 500. +func TestRelance500ExterneInchangee(t *testing.T) { + testHome(t) + var mu sync.Mutex + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies = append(bodies, body) + n := len(bodies) + mu.Unlock() + if n == 1 { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(sseChunk("voici") + sseStop)) + })) + t.Cleanup(srv.Close) + if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil { + t.Fatal(err) + } + if _, err := runChat(t.Context(), []Message{um("question")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(bodies) != 2 { + t.Fatalf("%d requêtes, attendu 2", len(bodies)) + } + if _, ok := bodies[1]["tools"]; ok { + t.Fatal("preset externe : la relance doit retirer les outils, comme avant") + } + if _, ok := bodies[1]["tool_choice"]; ok { + t.Fatal("preset externe : pas de tool_choice") + } +} diff --git a/internal/loki/llm_retry_net_test.go b/internal/loki/llm_retry_net_test.go index 22d90db..eb56340 100644 --- a/internal/loki/llm_retry_net_test.go +++ b/internal/loki/llm_retry_net_test.go @@ -99,10 +99,11 @@ func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) { // échecs déterministes ; attendre puis rejouer à l'identique ne ferait que // retarder l'erreur de plusieurs secondes. // -// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol, -// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est -// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la -// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est +// Le tour peut malgré tout repartir : le filet de compaction en vol, antérieur +// à ce fichier, retente le tour avec un historique résumé (le 500 est souvent +// un dépassement de contexte), et la relance d'un appel d'outil mal formé +// essaie tool_choice « none » puis, refusée elle aussi, le retrait des outils. +// Ce sont des rejeux SÉMANTIQUES — ils changent la requête et n'attendent pas. La preuve qu'aucune reprise réseau n'a eu lieu est // donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente. func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) { testHome(t) @@ -119,9 +120,9 @@ func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) { if elapsed >= llmNetWait { t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed) } - // Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du - // budget réseau (1 + 3 = 4 appels). - if n := calls.Load(); n > 2 { + // Borne haute : l'appel, la relance « none » et le repli sans outils. Trois + // reprises réseau en ajouteraient au moins trois. + if n := calls.Load(); n > 3 { t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n) } }