package loki import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "sort" "strconv" "strings" "syscall" "time" "unicode/utf8" ) // Message is one entry in the chat history sent to llama.cpp. // `Content` may be nil when an assistant message only contains tool_calls. type Message struct { Role string `json:"role"` Content any `json:"content,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` Function ToolCallFunc `json:"function"` } type ToolCallFunc struct { Name string `json:"name"` Arguments string `json:"arguments"` } type Tool struct { Type string `json:"type"` Function ToolFunction `json:"function"` } type ToolFunction struct { Name string `json:"name"` Description string `json:"description"` Parameters any `json:"parameters"` } // Tool definitions: OpenAI-shaped function schemas advertised to the model when // the agent mode is on. The memory tools (mem_*) let the model keep persistent // Markdown notes across sessions; bash is its real access to the machine. func memSearchTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "mem_search", Description: "Search your memory (Markdown pages under memory/) → ranked {file, title, snippet}. Use FIRST when the user mentions something you might already know, then mem_read the best page.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{"type": "string", "description": "Keywords"}, "limit": map[string]any{"type": "integer", "description": "Default 8, max 30"}, }, "required": []string{"query"}, }, }, } } func memReadTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "mem_read", Description: "Read a memory page. Lines prefixed with their 1-indexed number; offset/limit for long pages.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"}, "offset": map[string]any{"type": "integer", "description": "Start line (default 1)"}, "limit": map[string]any{"type": "integer", "description": "Lines (max 500)"}, }, "required": []string{"file"}, }, }, } } func memAddTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "mem_add", Description: "Create a memory page. One topic per page, kebab-case name, first line = title (#). Refuses to overwrite an existing page (use mem_edit).", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Page name"}, "content": map[string]any{"type": "string", "description": "Markdown, first line = title #"}, }, "required": []string{"file", "content"}, }, }, } } func memEditTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "mem_edit", Description: "Patch a memory page: old → new, old unique in the page. To append, put the current end of the page in old and the extended version in new.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Page name"}, "old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"}, "new": map[string]any{"type": "string", "description": "Replacement"}, }, "required": []string{"file", "old", "new"}, }, }, } } func editTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "edit", Description: "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Path"}, "old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"}, "new": map[string]any{"type": "string", "description": "Replacement"}, }, "required": []string{"file", "old", "new"}, }, }, } } func writeTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "write", Description: "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Path"}, "content": map[string]any{"type": "string", "description": "Full content"}, }, "required": []string{"file", "content"}, }, }, } } func bashTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "bash", Description: "Run a shell command (" + agentTargetShellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{"type": "string", "description": "The command"}, "timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout s (default %d, max %d)", toolDefaultTimeout, toolMaxTimeout)}, }, "required": []string{"command"}, }, }, } } // Caps are the per-request capabilities (tool access) for a chat turn. They let // a caller (e.g. an ajean.link agent with its own tools/skills toggles) scope // what the model can do for this conversation, instead of always inheriting the // machine's global config. Use globalCaps() to fall back to the global config. type Caps struct { // Agent = mode agent actif : un seul interrupteur qui débloque TOUS les // outils de l'IA (shell + skills). Un skill est un outil comme un autre. Agent bool // Internet = accès web actif (serveur Crawl4AI configuré + joignable) : ajoute // les outils web_search/web_open/web_read/web_grep. Requiert aussi Agent. Internet bool // Mem = mode d'accès à la mémoire persistante (off / ondemand / always), // indépendant du mode agent. Voir MemMode. Mem MemMode // Code = mode code actif pour cette discussion (sélecteur Chat/Code) : // ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le // prompt de rôle. Requiert Agent. Code bool // Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner", // "verifier" (passe de vérification, seule autorisée à marquer un critère // passed), "explorer", "code-reviewer". Voir code_roles.go. Role string } // globalCaps reads the machine-wide config — the default when a request doesn't // specify its own capabilities. func globalCaps() Caps { // Internet inclut la joignabilité du serveur Crawl4AI : « actif ET fonctionnel ». // Ainsi le prompt système (chat_tools.go) et les outils fournis (EnabledTools) sont // gouvernés par la MÊME condition — sinon le prompt promet web_search alors que // l'outil n'existe pas, et le modèle le tape en bash (command not found). // Mémoire et accès internet sont des sous-réglages du mode agent (cf. UI web) : // sans agent, on ne fournit NI les outils mem_*, NI les outils web. Ça garde le // prompt système et les outils cohérents avec l'interface (blocs grisés quand // l'agent est off) — l'IA en chat pur répond sans mémoire ni web. agent := agentEnabled() if !agent { return Caps{Agent: false, Internet: false, Mem: MemOff} } return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()} } // InjectSkills prepends context system messages to msgs: the decisive-agent // preamble + machine briefing (when tools are enabled) and the lightweight // skills directory (when skills are enabled). Merges with an existing system // message if present. func InjectSkills(msgs []Message, caps Caps) []Message { var parts []string // Decisive-agent preamble (anti-loop) — only when the model actually has // tools, otherwise it nudges a plain chat model to "call tools" it doesn't // have, which leaks malformed tool-call text into the answer. if bp := baseSystemPrompt(caps); bp != "" { parts = append(parts, bp) } if mp := machineSystemPrompt(caps); mp != "" { parts = append(parts, mp) } if len(parts) == 0 { return msgs } prefix := strings.Join(parts, "\n\n") if len(msgs) > 0 && msgs[0].Role == "system" { existing, _ := msgs[0].Content.(string) merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) return merged } return append([]Message{{Role: "system", Content: prefix}}, msgs...) } // steerSystem ajoute une consigne système SANS jamais créer un second message // système ailleurs qu'en tête : elle est fusionnée dans celui d'ouverture, ou // posée en première position s'il n'y en a pas. // // Un message système ajouté À LA FIN faisait échouer le tour sur les modèles // dont le gabarit l'interdit : gpt-oss (rôle « developer ») lève « System // message must be at the beginning », et ce 500 du gabarit remplaçait l'erreur // d'origine que la nouvelle tentative cherchait justement à contourner — // l'utilisateur voyait une exception Jinja au lieu du vrai problème. func steerSystem(msgs []Message, hint string) []Message { if len(msgs) > 0 && msgs[0].Role == "system" { if existing, ok := msgs[0].Content.(string); ok { out := append([]Message(nil), msgs...) out[0] = Message{Role: "system", Content: existing + "\n\n" + hint} return out } } return append([]Message{{Role: "system", Content: hint}}, msgs...) } // normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la // séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26). // // steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais // il ne couvre que SES propres consignes : un historique venu d'ailleurs (import, // preset, conversation migrée, agent de code qui compose sa propre séquence) peut // encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les // gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must // be at the beginning », un 500 qui tue le tour sans rien expliquer. // // La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour // l'affichage, la persistance et la compaction. Un modèle qui accepte le système // n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc // sans risque pour tous, et sans effet sur une conversation déjà normale (payload // identique, garanti par test). func normalizeSystemMessages(msgs []Message) []Message { var sys []string sawSystem := false rest := make([]Message, 0, len(msgs)) for _, m := range msgs { if m.Role == "system" { if s, ok := m.Content.(string); ok { sawSystem = true if strings.TrimSpace(s) != "" { sys = append(sys, s) } continue } // Contenu système non textuel (cas théorique) : on le préserve tel quel // plutôt que de le perdre. } rest = append(rest, m) } // Aucun message système : rien à réordonner, on rend l'entrée telle quelle — // pas de copie inutile sur le chemin le plus fréquent. if !sawSystem { return msgs } // Des systèmes existaient mais tous vides : on les a retirés (un système vide // hors position 0 casserait tout autant), sans en réinsérer. if len(sys) == 0 { return rest } out := make([]Message, 0, len(rest)+1) out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")}) return append(out, rest...) } // EnabledTools returns the tools to advertise on the next inference call. func EnabledTools(caps Caps) []Tool { tools := []Tool{} // Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture // seule + bash (lancer les tests) + critères. Ni write/edit (elle ne // corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du // contexte). if caps.Code && caps.Role == "verifier" { return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()} } if caps.Agent { tools = append(tools, bashTool(), writeTool(), editTool()) } if caps.Code { tools = append(tools, readTool(), grepTool(), globTool(), askTool(), bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool()) } // Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès // que le mode mémoire n'est pas « off » (que l'agent soit actif ou non). if caps.Mem != MemOff { tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool()) } // Outils web : seulement si le mode agent ET l'accès internet sont actifs. // caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go), // donc prompt et outils restent cohérents — pas de web_search halluciné. if caps.Agent && caps.Internet { tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool()) } // Capture d'écran : seulement si Playwright est réellement présent dans // l'image (annoncer un outil absent enverrait le modèle en boucle de // réessai). Offerte aussi SANS accès internet quand le mode code est actif : // photographier son propre serveur de dev est une vérification locale, pas // une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun // navigateur et perdait des minutes à installer puppeteer. La borne aux // adresses locales vit dans toolWebScreenshot. if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() { tools = append(tools, webScreenshotTool()) } // Outils MCP : serveurs tiers configurés par le propriétaire de la machine. // Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode // agent. La découverte est paresseuse et cachée (voir mcp_client.go). if caps.Agent { tools = append(tools, mcpTools()...) } // Postes distants : PAS de nouveaux outils. L'IA garde bash/write/edit ; c'est // leur CIBLE D'EXÉCUTION qui change quand un poste est sélectionné (voir le // routage dans la boucle d'outils et agentTargetSlug). Redonner des outils que // le modèle a déjà (node__…__shell alors qu'il a bash) doublonnait le catalogue. return tools } // StreamEvent is what a ChatCallback receives for each piece of streamed output. // Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set // per call. type StreamEvent struct { Content string Reasoning string ToolUsed *ToolUsedEvent Stats *StatsEvent Ask *AskEvent Err error // DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement : // le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là // est mort-né et ne doit pas rester à l'écran (sinon double raisonnement). DropReasoning bool // Compacting signale une compaction déclenchée EN COURS DE TOUR (boucle // d'outils) : true à l'entrée du résumé, false à la sortie. Même bannière que // la compaction proactive de début de tour, qui elle est émise directement par // Conversation.generate. nil = l'événement ne parle pas de compaction. Compacting *bool // NewHistory publie la vue modèle APRÈS une compaction faite en cours de tour. // Sans elle la compaction est perdue : l'appelant reconstruit l'historique en // ajoutant les messages du tour à sa copie d'AVANT compaction, donc le fil // complet revient et le tour suivant re-déborde aussitôt (43% → 92% en un // message). Cette liste contient DÉJÀ tout le tour en cours : l'appelant doit // REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y // ajouter. NewHistory []Message } // AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue // par l'UI comme une carte à boutons. Le tour se termine juste après. type AskEvent struct { Question string `json:"question"` Options []string `json:"options,omitempty"` } type ToolUsedEvent struct { Name string Label string // user-visible summary (skill name or the command) Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill) Done bool // false = call announced (command only); true = result is ready Typing bool // true = command still being written (partial), no spinner yet // Body : contenu en cours d'écriture par un outil d'écriture (write/edit, // mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape, // pour que la bulle se remplisse en direct au lieu de rester figée puis de // s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué. Body string // Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose // (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-). Diff []DiffLine // Image : chemin (relatif au dossier de travail) d'une image PRODUITE par // l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la // bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle // pensait à recopier la ligne markdown rendue par l'outil : un petit modèle // l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée. Image string } // shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI. // Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et // web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette // « ~N tok » de la bulle dit la VRAIE taille du résultat. // // Avant, cette borne était à 4000 : toute page web un peu longue s'affichait // « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur // mentait, et il mentait toujours avec le même chiffre. const shownDisplayMax = 12000 // shownResult prépare un résultat d'outil pour l'affichage. func shownResult(s string) string { if r := []rune(s); len(r) > shownDisplayMax { return string(r[:shownDisplayMax]) + "\n…[tronqué]" } return s } // repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un // appel RIGOUREUSEMENT identique (même outil, mêmes arguments) dans le même tour. // L'appel n'est jamais rejoué — on répond depuis le résultat mémorisé. // // Le plafond d'itérations et l'anti-boucle ont été retirés en v0.6.3 parce qu'ils // coupaient des recherches légitimes ; il ne restait donc plus RIEN pour arrêter // un modèle qui redemande dix fois la même page. Et le mécanisme se retournait // contre lui-même : la note « déjà exécuté » était collée APRÈS le contenu, donc // noyée en fin d'un résultat de plusieurs milliers de caractères — le modèle // voyait le contenu, pas l'avertissement, et recommençait. // // D'où l'escalade : la note passe EN TÊTE, et à partir de la 2ᵉ redemande on ne // renvoie plus la charge utile du tout. Le tour n'est pas coupé (le modèle garde // la main), mais redemander la même chose ne rapporte plus rien — ni contenu, ni // contexte consommé. func repeatedCallResult(prev string, repeats int) string { if repeats >= 2 { return "[déjà fait] Cet appel exact a déjà été exécuté " + strconv.Itoa(repeats) + " fois dans ce tour ; son résultat est plus haut dans la conversation. " + "Ne le redemande plus : réponds avec ce que tu as, ou change d'approche " + "(autre URL, autres arguments, web_grep pour cibler)." } return "[déjà fait] Appel identique déjà exécuté dans ce tour — non rejoué. " + "Voici à nouveau son résultat ; ne le redemande pas une troisième fois.\n\n" + prev } // writeBodyKey returns the argument holding the text an écriture tool is about // to commit — the part worth showing live in the bubble — or "" for tools that // have no such body (bash, lectures, recherches). func writeBodyKey(tool string) string { switch tool { case "write", "mem_add": return "content" case "edit", "mem_edit": return "new" } return "" } // previewArg pulls the (possibly incomplete) string value of key out of a // streaming tool-call arguments JSON, so the UI can show the command being // typed live. Best-effort: it tolerates a truncated tail and basic escapes. func previewArg(args, key string) string { i := strings.Index(args, "\""+key+"\"") if i < 0 { return "" } rest := args[i+len(key)+2:] if j := strings.Index(rest, ":"); j >= 0 { rest = rest[j+1:] } else { return "" } q := strings.Index(rest, "\"") if q < 0 { return "" } rest = rest[q+1:] var b strings.Builder for x := 0; x < len(rest); x++ { c := rest[x] if c == '\\' && x+1 < len(rest) { switch rest[x+1] { case 'n': b.WriteByte('\n') case 't': b.WriteByte('\t') case 'r': case '"': b.WriteByte('"') case '\\': b.WriteByte('\\') default: b.WriteByte(rest[x+1]) } x++ continue } if c == '"' { break } b.WriteByte(c) } return b.String() } // StatsEvent carries llama.cpp's per-completion timing (final chunk). type StatsEvent struct { PromptTokens int `json:"prompt_tokens,omitempty"` PromptPerSecond float64 `json:"prompt_per_second,omitempty"` PromptMs float64 `json:"prompt_ms,omitempty"` GenTokens int `json:"gen_tokens,omitempty"` GenPerSecond float64 `json:"gen_per_second,omitempty"` GenMs float64 `json:"gen_ms,omitempty"` // Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de // `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage. PromptTokensTotal int `json:"prompt_tokens_total,omitempty"` } // ChatCallback receives stream events. Return false to abort the stream. type ChatCallback func(StreamEvent) bool // completionResp / streamChunk model the subset of llama.cpp's // OpenAI-compatible /v1/chat/completions response that we care about. type streamChunk struct { Choices []struct { Delta struct { Content string `json:"content"` ReasoningContent string `json:"reasoning_content"` ToolCalls []ToolCall `json:"tool_calls"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` // llama.cpp's "timings" appears on the final chunk and on intermediate // /completion endpoint responses. Snake-case mapping per llama.cpp source. Timings *struct { PromptN int `json:"prompt_n"` PromptMs float64 `json:"prompt_ms"` PromptPerSecond float64 `json:"prompt_per_second"` PredictedN int `json:"predicted_n"` PredictedMs float64 `json:"predicted_ms"` PredictedPerSec float64 `json:"predicted_per_second"` } `json:"timings"` // Chunk final (include_usage) : taille totale du prompt, hors choices. Usage *struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` } // runChat drives the full inference loop including tool calling. // On finish_reason="tool_calls" we execute locally, append a "tool" message // and call /v1/chat/completions again — up to 8 iterations as a safety cap. const thinkClose = "" // 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. // friendlyLLMError transforme une erreur de transport vers llama-server en un // message clair. Le cas le plus fréquent — « connection refused » (Linux) / // « actively refused » (Windows) — arrive quand le moteur redémarre ou charge // encore le modèle ; l'erreur brute (« dial tcp 127.0.0.1:8080… ») ne dit rien à // l'utilisateur. On garde le silence sur une annulation volontaire (/stop). // On classe d'abord sur les erreurs TYPÉES (errors.Is / net.Error) : la // reconnaissance par sous-chaîne dépend de la langue et du format des messages // de l'OS, et « connexion refusée » d'un Windows français ne ressemble à aucun // des motifs anglais. Les sous-chaînes restent en second rideau, pour les // erreurs enveloppées par une bibliothèque qui perd le type d'origine. func friendlyLLMError(err error) error { if err == nil { return nil } if errors.Is(err, context.Canceled) { return err // /stop : pas d'alarme } switch { case errors.Is(err, syscall.ECONNREFUSED): return errEngineDown() case errors.Is(err, context.DeadlineExceeded), isNetTimeout(err): return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort()) case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, syscall.ECONNRESET): return errEngineReset() } low := strings.ToLower(err.Error()) switch { case strings.Contains(low, "context canceled"): return err case strings.Contains(low, "connection refused"), strings.Contains(low, "actively refused"), strings.Contains(low, "connectex"), strings.Contains(low, "no connection could be made"): return errEngineDown() case strings.Contains(low, "timeout"), strings.Contains(low, "deadline exceeded"): return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort()) case strings.Contains(low, "eof"), strings.Contains(low, "connection reset"): return errEngineReset() } return err } func errEngineDown() error { return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort()) } func errEngineReset() error { return fmt.Errorf("⚠️ Connexion au moteur (llama-server, port %d) interrompue — il a peut-être redémarré. Réessaie.", LLMPort()) } // streamCutError explique un flux de complétion coupé en cours de route. Cas à // part de friendlyLLMError : ici la requête avait ABOUTI (200 reçu, tokens déjà // reçus), c'est la lecture qui a lâché. Le dire autrement qu'un « le moteur ne // répond pas » évite d'envoyer l'utilisateur vérifier un moteur qui va bien. func streamCutError(err error) error { if errors.Is(err, bufio.ErrTooLong) { return fmt.Errorf("⚠️ Réponse du moteur illisible : une ligne du flux dépasse la taille maximale (%d Mio). C'est presque toujours un appel d'outil démesuré (écriture d'un très gros fichier). Le tour est abandonné pour ne pas exécuter un appel tronqué.", 8) } return fmt.Errorf("⚠️ Le flux de réponse du moteur (llama-server, port %d) a été coupé en cours de route : %v. La réponse est incomplète et le tour est abandonné — réessaie.", LLMPort(), err) } // isNetTimeout : une erreur réseau qui se déclare elle-même comme un délai // dépassé (net.Error.Timeout), quel que soit son libellé. func isNetTimeout(err error) bool { var ne net.Error return errors.As(err, &ne) && ne.Timeout() } 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) // arrives inline in `content`, terminated by a literal . When // reasoning is enabled we split that out ourselves so the UI's reasoning // bubble works regardless of backend. The ik_llama.cpp fork already sends // reasoning_content, in which case we leave content untouched. chatCfg := ReadConfig() reasoningOn := reasoningActive(chatCfg["REASONING"]) // Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide // = on n'envoie rien. Réglage par preset, donc de fait par modèle. effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"]) // …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) : // on part alors directement sur la traduction apprise, sans repayer le 500. reasoningEffort := effortResolve(effortWanted) // Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une // interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est // `enable_thinking` qui porte alors la consigne, seul. thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none" // Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort` // ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles // hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le // modèle réfléchissait pendant que l'interface annonçait le contraire. reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort) // When llama.cpp fails to parse a model-generated tool call (HTTP 500), we // 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 // 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. compactedRetry := false // Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par // tour, comme les autres filets. effortRetried := false // Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas // rejouer deux fois exactement la même écriture dans un même échange. doneCalls := map[string]string{} // Nombre de fois où le modèle a REDEMANDÉ un appel déjà exécuté. Sert à durcir // la réponse progressivement (voir repeatedCallResult) : rendre le contenu une // fois, puis refuser en le renvoyant vers ce qu'il a déjà. repeatCount := map[string]int{} // Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un // appel d'outil dans leur puis émettent le token de fin SANS l'émettre // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge // explicite au lieu d'afficher « pas de réponse ». nudged := false // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule // relance par tour, comme le nudge. patternRetried := false // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui // demande de conclure — de la pression, pas une barrière. Sans ça, un modèle // qui tourne en rond n'avait rien en face de lui sauf le bouton stop. toolRuns, budgetNudges, budget := 0, 0, agentBudget() for iter := 0; ; iter++ { // 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. if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { budgetNudges++ logBudget(toolRuns, budget, budgetNudges) messages = append(messages, Message{Role: "user", Content: msg}) } payload := map[string]any{ "model": "loki", // 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. "messages": normalizeSystemMessages(messages), "stream": true, "temperature": temperature, // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE // du prompt (préfixe caché compris), contrairement à timings.prompt_n qui // ne compte que les tokens nouvellement traités. Sert au comptage exact du // contexte (sinon le system prompt déjà en cache n'est pas recompté). "stream_options": map[string]any{"include_usage": true}, } // Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui // l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les // deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que // c'est bien loki qui a le dernier mot sur `chat_template_kwargs`. applySampling(payload) if reasoningEffort != "" { payload["reasoning_effort"] = reasoningEffort } if reasoningKwargs != nil { payload["chat_template_kwargs"] = reasoningKwargs } if len(tools) > 0 && !disableTools { payload["tools"] = tools // The model sometimes emits parallel tool calls, which this llama.cpp // build serialises as two concatenated JSON objects in one arguments // string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a // single tool call per turn avoids that. payload["parallel_tool_calls"] = false } body, _ := json.Marshal(payload) url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return extra, err } req.Header.Set("Content-Type", "application/json") authHeader(req) resp, err := http.DefaultClient.Do(req) if err != nil { err = friendlyLLMError(err) cb(StreamEvent{Err: 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 // body, find no data lines, and return silently — the chat just stops // with no answer. Surface the body so the cause is visible instead. if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2000)) resp.Body.Close() msg := strings.TrimSpace(string(b)) if msg == "" { msg = resp.Status } // Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne // connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un // refus DÉFINITIF, pas une question de taille de prompt : à traiter // avant la compaction, qui sinon taillait l'historique pour rien puis // échouait quand même. On rejoue le tour avec le niveau accepté (ou // sans le champ), l'historique intact. if !effortRetried && effortWanted != "" { if fixed, ok := effortFromRejection(msg, effortWanted); ok { effortRetried = true effortRemember(effortWanted, fixed) logEffortFallback(effortWanted, fixed) reasoningEffort = fixed reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed) continue } } // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une // compaction en vol et on rejoue le tour (une seule fois) avant tout le // reste. C'est le filet de secours à la Hermes. if compactEnabled() && !compactedRetry { if c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true // ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre // passait `messages` déjà remplacé comme état « avant », donc la // ligne comparait le résultat à lui-même et n'apprenait rien. logCompact("réactif", 0, messages, c, changed) messages = c // Même publication qu'en cours de tour : sans elle, la compaction de // secours ne survit pas à la fin du tour et le prompt re-déborde au // message suivant. extra = nil cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) continue } } // Most common 500 here: llama.cpp couldn't parse a malformed tool call // the model emitted. Retry the turn once without tools so it answers // in plain text rather than leaving the chat dead. if !disableTools && len(tools) > 0 { 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, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.") continue } err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg) cb(StreamEvent{Err: err}) return extra, err } toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" // Accumulateur de stats : timings (prefill/decode) puis usage (total prompt) // arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ // pour que les consommateurs (terminal, web) aient toujours tout. var stats StatsEvent lastPreview := "" // last command preview emitted (to stream the typing) lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture // Per-completion reasoning-split state (see reasoningOn comment above). sawReasoningField := false thinkOpen := reasoningOn var thinkTail strings.Builder // Scanner à gros tampon : un chunk peut porter un gros JSON d'arguments // (écriture de fichier). 8 Mio et non 1 : au-delà du tampon, le scanner // s'arrête sur « token too long » AU MILIEU du flux, et jusqu'à la 0.8.4 // personne ne le voyait (voir sc.Err() plus bas). sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 8<<20) aborted := false for sc.Scan() { line := strings.TrimSpace(sc.Text()) if !strings.HasPrefix(line, "data:") { continue } data := strings.TrimSpace(line[5:]) if data == "" || data == "[DONE]" { continue } var chunk streamChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue } // Timings ET usage (include_usage) arrivent sur le CHUNK FINAL qui, sur ce // build llama.cpp (MTP/spéculatif), a `choices:[]` — on les traite AVANT le // garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés // et l'UI retombe à « 0 tok/s » à la fin de la génération. if chunk.Timings != nil { stats.PromptTokens = chunk.Timings.PromptN stats.PromptPerSecond = chunk.Timings.PromptPerSecond stats.PromptMs = chunk.Timings.PromptMs stats.GenTokens = chunk.Timings.PredictedN stats.GenPerSecond = chunk.Timings.PredictedPerSec stats.GenMs = chunk.Timings.PredictedMs s := stats cb(StreamEvent{Stats: &s}) } if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 { stats.PromptTokensTotal = chunk.Usage.PromptTokens s := stats cb(StreamEvent{Stats: &s}) } if len(chunk.Choices) == 0 { continue } ch := chunk.Choices[0] if ch.FinishReason != "" { finishReason = ch.FinishReason } if 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 // (qui coupe la bulle en cours à chaque tool_used) affichait la fin // de la phrase — souvent coupée en plein mot — dans une bulle // séparée sous l'outil. if thinkOpen && thinkTail.Len() > 0 { tail := thinkTail.String() thinkTail.Reset() if !cb(StreamEvent{Content: tail}) { aborted = true break } } for i, tc := range ch.Delta.ToolCalls { // llama.cpp's stream may omit index; fall back to slot i. idx := i cur, ok := toolCalls[idx] if !ok { cur = &ToolCall{Type: "function"} toolCalls[idx] = cur } if tc.ID != "" { cur.ID = tc.ID } if tc.Function.Name != "" { cur.Function.Name = tc.Function.Name } cur.Function.Arguments += tc.Function.Arguments } // Stream the command being typed: extract the partial value and // emit it whenever it grows, so the UI shows it appear live. if cur := toolCalls[0]; cur != nil { key := "command" switch cur.Function.Name { case "mem_search", "web_search": key = "query" case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": key = "file" case "web_open", "web_read", "web_grep": key = "url" case "grep", "glob": key = "pattern" case "ask": key = "question" case "criteria": key = "action" case "bash_tail": key = "id" case "git_clone": key = "url" } p := previewArg(cur.Function.Arguments, key) // Corps en cours de frappe pour les outils d'écriture : on le diffuse // à la LIGNE, pas au token. Un événement par token republierait tout le // contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait // le rendu mobile) ; à la ligne, le nombre d'événements est celui du // fichier et l'animation reste fluide. body := "" if bk := writeBodyKey(cur.Function.Name); bk != "" { body = previewArg(cur.Function.Arguments, bk) } grew := body != "" && strings.Count(body, "\n") > lastBodyLines if (p != "" && p != lastPreview) || grew { if p != "" { lastPreview = p } if grew { lastBodyLines = strings.Count(body, "\n") } if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) { aborted = true break } } } continue } if ch.Delta.ReasoningContent != "" { // Backend already separates reasoning — trust it, disable our split. sawReasoningField = true thinkOpen = false if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { aborted = true break } } if ch.Delta.Content != "" { assistantContent.WriteString(ch.Delta.Content) if !thinkOpen || sawReasoningField { if !cb(StreamEvent{Content: ch.Delta.Content}) { aborted = true break } } else { // The prompt opened a block. Stream `content` LIVE as // the answer, holding back only a short tail that could be the // start of a literal "". A reasoning-aware backend // (llama.cpp with --reasoning-format, Nathan's fork) strips the // think tags server-side, so never appears in content // and the whole answer streams straight through — including // when the model answers WITHOUT thinking (no reasoning_content, // no ), which is exactly what used to get dumped into // the reasoning bubble. A vanilla build that leaves the thinking // inline still gets carved at the below. thinkTail.WriteString(ch.Delta.Content) s := thinkTail.String() if i := strings.Index(s, thinkClose); i >= 0 { // Vanilla inline think: reasoning before , answer // after. Route the reasoning to its bubble, drop the tag. reason := s[:i] after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n") thinkOpen = false thinkTail.Reset() if reason != "" && !cb(StreamEvent{Reasoning: reason}) { aborted = true break } if after != "" && !cb(StreamEvent{Content: after}) { aborted = true break } } else { // No yet: stream as content, holding back a tail // that could be a partial "". Back the cut up to a // UTF-8 rune boundary so a multi-byte char (é, …) is never // split — otherwise the two halves decode as � (mojibake). cut := len(s) - (len(thinkClose) - 1) for cut > 0 && !utf8.RuneStart(s[cut]) { cut-- } if cut > 0 { emit := s[:cut] thinkTail.Reset() thinkTail.WriteString(s[cut:]) if !cb(StreamEvent{Content: emit}) { aborted = true break } } } } } } // Flush the held-back tail (never part of a ): it's answer text. if !aborted && thinkOpen && thinkTail.Len() > 0 { cb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")}) } // ⚠️ Le flux a-t-il fini, ou CASSÉ ? sc.Scan() renvoie false dans les deux // cas, et l'erreur n'était jamais consultée : une lecture coupée en plein // milieu (connexion réinitialisée, ligne plus longue que le tampon) était // donc indiscernable d'une fin normale. Vécu par l'utilisateur : l'agent // enchaîne quelques commandes puis « s'arrête et rend la main sans avoir // terminé, ni même commenté », pendant que le journal de llama-server // affiche un « stop processing » parfaitement normal (issue #19). Pire, // des appels d'outils accumulés à moitié auraient été EXÉCUTÉS avec des // arguments tronqués. On refuse donc le tour, en le disant. scanErr := sc.Err() resp.Body.Close() if aborted { return extra, nil } if scanErr != nil && ctx.Err() == nil { err := streamCutError(scanErr) cb(StreamEvent{Err: err}) return extra, err } // Treat any accumulated tool calls as a tool turn even if the backend set // finish_reason to "stop" instead of "tool_calls" (some llama.cpp builds // do this) — otherwise we'd skip execution AND skip answering. if len(toolCalls) > 0 { // 1. Append assistant message with tool_calls so the model sees its own decision next turn. idxs := make([]int, 0, len(toolCalls)) for k := range toolCalls { idxs = append(idxs, k) } sort.Ints(idxs) tcs := make([]ToolCall, 0, len(idxs)) for i, k := range idxs { tc := *toolCalls[k] if tc.ID == "" { tc.ID = fmt.Sprintf("call_%d_%d", iter, i) } // Les arguments DOIVENT être du JSON valide : ils sont rangés dans // l'historique vu par le modèle, et le template de chat de llama.cpp les // re-parse à CHAQUE requête suivante. Un modèle très quantifié peut sortir // des arguments vides OU tronqués/non-JSON (ex: `{"command":"python3 …`) ; // stockés tels quels, ils font échouer le parsing du template → 500 en // boucle jusqu'au reset. On neutralise tout ce qui n'est pas du JSON valide // en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("") // étant faux, ça couvre aussi le cas vide d'origine. if !json.Valid([]byte(tc.Function.Arguments)) { tc.Function.Arguments = "{}" } tcs = append(tcs, tc) } assistant := Message{Role: "assistant", ToolCalls: tcs} if s := assistantContent.String(); s != "" { assistant.Content = s } messages = append(messages, assistant) extra = append(extra, assistant) // 2. Execute each tool locally and append a "tool" reply. for _, tc := range tcs { // Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce // garde, un stop pendant une série d'appels laissait défiler toute // la série avant de reprendre la main. if ctx.Err() != nil { return extra, nil } toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can // announce the call BEFORE running it — otherwise the UI shows // nothing while a slow shell command runs and looks frozen. label := "" switch tc.Function.Name { case "mem_search", "web_search": label, _ = args["query"].(string) case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": label, _ = args["file"].(string) case "bash", "bash_bg": label, _ = args["command"].(string) case "grep", "glob": label, _ = args["pattern"].(string) case "ask": label, _ = args["question"].(string) case "criteria": label, _ = args["action"].(string) case "bash_tail": label, _ = args["id"].(string) case "git_clone": label, _ = args["url"].(string) case "web_open", "web_read": label, _ = args["url"].(string) case "web_grep": u, _ := args["url"].(string) p, _ := args["pattern"].(string) label = p + " @ " + u default: // Outils MCP : libellé = un aperçu compact des arguments. if isMCPTool(tc.Function.Name) { label = mcpArgLabel(args) } } cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) result := "" // diff : rempli par les outils d'écriture (edit / mémoire) pour que // l'UI montre les lignes ajoutées et retirées. var diff []DiffLine // Appel rigoureusement identique déjà exécuté dans ce tour : on ne le // rejoue pas. Les petits modèles réémettent volontiers deux fois la // même écriture ; la rejouer produisait une fausse erreur (« old // introuvable », puisque le remplacement est déjà fait). callKey := tc.Function.Name + "\x00" + tc.Function.Arguments if prev, seen := doneCalls[callKey]; seen { repeatCount[callKey]++ result = repeatedCallResult(prev, repeatCount[callKey]) cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) continue } switch tc.Function.Name { case "mem_search": lim := 0 if v, ok := args["limit"].(float64); ok { lim = int(v) } hits := MemSearch(label, lim) if len(hits) == 0 { result = "[aucun résultat]" } else { var b strings.Builder for _, h := range hits { fmt.Fprintf(&b, "- %s — %s\n %s\n", h.File, h.Title, h.Snippet) } result = strings.TrimRight(b.String(), "\n") } case "mem_read": off, lim := 0, 0 if v, ok := args["offset"].(float64); ok { off = int(v) } if v, ok := args["limit"].(float64); ok { lim = int(v) } if c, rerr := MemRead(label, off, lim); rerr != nil { result = "[erreur] " + rerr.Error() } else { result = c } case "mem_add": content, _ := args["content"].(string) if werr := MemAdd(label, content); werr != nil { result = "[erreur] " + werr.Error() } else { result = fmt.Sprintf("[ok] page '%s' créée", label) diff = addedDiff(content) } case "mem_edit": oldText, _ := args["old"].(string) newText, _ := args["new"].(string) if werr := MemEdit(label, oldText, newText); errors.Is(werr, errAlreadyApplied) { result = fmt.Sprintf("[ok] page '%s' %s", label, werr.Error()) } else if werr != nil { result = "[erreur] " + werr.Error() } else { result = fmt.Sprintf("[ok] page '%s' modifiée", label) diff = lineDiff(oldText, newText) } case "write": content, _ := args["content"].(string) if tgt := agentTargetSlug(); tgt != "" { // Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on // n'a pas l'ancien contenu du fichier distant). result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content}) } else if msg := codeWriteGuard(caps, label, true); msg != "" { result = msg } else { result = fileWrite(label, content) if !strings.HasPrefix(result, "[erreur]") { diff = addedDiff(content) trackerNoteWrite(resolveAgentPath(label)) result += lspDiagBlock(resolveAgentPath(label), caps) } } case "edit": oldText, _ := args["old"].(string) newText, _ := args["new"].(string) if tgt := agentTargetSlug(); tgt != "" { result = nodeEditRemote(tgt, label, oldText, newText) } else if msg := codeWriteGuard(caps, label, false); msg != "" { result = msg } else { result = fileEdit(label, oldText, newText) // Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé). if !strings.HasPrefix(result, "[erreur]") { diff = lineDiff(oldText, newText) trackerNoteWrite(resolveAgentPath(label)) result += lspDiagBlock(resolveAgentPath(label), caps) } } case "read": result = toolRead(args, caps.Code) case "grep": result = toolGrep(args, caps.Code) case "glob": result = toolGlob(args, caps.Code) case "ask": result = toolAsk(args) if !strings.HasPrefix(result, "[erreur]") { q, _ := args["question"].(string) cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}}) } case "bash_bg": result = toolBashBg(args) case "bash_tail": result = toolBashTail(args) case "git_status": result = toolGitStatus(ctx) case "git_diff": result = toolGitDiff(ctx, args) case "git_clone": result = toolGitClone(ctx, args) case "criteria": result = toolCriteria(args, caps.Role == "verifier") case "bash": to := 0 switch v := args["timeout"].(type) { case float64: to = int(v) case int: to = v } // Politique de sécurité : les commandes catastrophiques sont // refusées AVANT toute exécution, cible distante comprise. if reason := dangerousCommand(label); reason != "" { result = refusedCommandResult(reason) break } if tgt := agentTargetSlug(); tgt != "" { // Cible = un poste distant : la commande s'exécute LÀ-BAS via le // canal du poste (fail-closed : nodeCall renvoie une erreur si le // poste est déconnecté, on n'exécute JAMAIS sur le serveur à sa place). result = nodeCall(tgt, nodeCapShell, map[string]any{"command": label, "timeout": to}) } else { result = runShell(ctx, label, to) } case "web_search": result = capWebOutput(toolWebSearch(args)) case "web_open": result = capWebOutput(toolWebOpen(args)) case "web_read": result = capWebOutput(toolWebRead(args)) case "web_grep": result = capWebOutput(toolWebGrep(args)) case "web_screenshot": result = toolWebScreenshot(args, caps) default: if isMCPTool(tc.Function.Name) { result = mcpCall(tc.Function.Name, args) } else { result = "[erreur] outil inconnu: " + tc.Function.Name } } if !strings.HasPrefix(result, "[erreur]") { doneCalls[callKey] = result } // Capture d'écran : l'image part avec l'événement pour être // affichée dans la bulle, quoi que le modèle en fasse ensuite. shot := "" if tc.Function.Name == "web_screenshot" { shot = capturedRelPath(result) } cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) // Capture d'écran + vision active : on fait SUIVRE l'image elle-même // dans un message `user`. Un message `tool` ne transporte que du // texte, donc sans ce relais le modèle recevait le chemin du fichier // et rien d'autre — il annonçait alors à l'utilisateur qu'il ne // voyait pas l'image, alors que le projecteur était bien chargé. // // ÉPHÉMÈRE : l'image va dans `messages` (le tour en cours) mais PAS // dans `extra` (l'historique persistant). Un base64 de capture pèse // des dizaines de milliers de tokens ; persisté, il était renvoyé à // CHAQUE tour suivant et la conversation dépassait définitivement le // contexte (vu en production : requêtes de 55 000 tokens pour une // fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde // l'image MAINTENANT et sa description textuelle, elle, reste. if tc.Function.Name == "web_screenshot" { if rel := capturedRelPath(result); rel != "" { if imgMsg, ok := screenshotImageMessage(rel); ok { messages = append(messages, imgMsg) } } } } // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : // une boucle d'outils peut à elle seule remplir la fenêtre (résultats // enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au // mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici, // avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré). if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) { yes, no := true, false cb(StreamEvent{Compacting: &yes}) c, changed := compactMessages(ctx, messages, caps) cb(StreamEvent{Compacting: &no}) logCompact("en-tour", used, messages, c, changed) if changed { messages = c // La nouvelle base contient déjà tout ce tour : on la publie et on // repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec // les messages du tour et dupliquerait tout. extra = nil cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) } } continue } // Appel d'outil TEXTUEL (halluciné, jamais exécuté) dans la réponse // finale : on relance le tour UNE fois avec la consigne corrective // (code_retry.go). Le texte fautif reste affiché — le remplacer serait // 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 !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) { patternRetried = true bad := Message{Role: "assistant", Content: assistantContent.String()} fix := Message{Role: "user", Content: retryCorrective} messages = append(messages, bad, fix) extra = append(extra, bad, fix) continue } // Normal end of turn. If the model produced no visible answer at all // (empty content, e.g. it stopped right after a tool result), say so // instead of leaving the user staring at a silent, finished chat. if strings.TrimSpace(assistantContent.String()) == "" { // Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) : // si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec // une consigne impérative au lieu d'afficher « pas de réponse ». if len(tools) > 0 && !disableTools && !nudged { nudged = true // 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}) messages = append(messages, Message{ Role: "user", Content: "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.", }) continue } cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) } return extra, nil } } // healthClient : /health doit répondre tout de suite ou pas du tout. Sans // timeout (http.Get et son client par défaut n'en ont aucun), un moteur qui // accepte la connexion sans jamais répondre — cas classique d'un très gros // modèle en cours de chargement, ou d'un process figé — bloquait healthCheck // indéfiniment. Et comme StartTurn commence par là, /api/chat/send restait // pendu : l'utilisateur voyait un bouton d'envoi qui ne rendait jamais la main. var healthClient = &http.Client{Timeout: 3 * time.Second} // healthCheck pings llama.cpp's /health endpoint. func healthCheck() bool { resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) if err != nil { return false } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) return resp.StatusCode == 200 }