Files
Loki/internal/loki/llm_client.go
T
MichaelandClaude Opus 5 4e5bc44607 Captures d'écran : montrées dans la bulle, sans dépendre du modèle
La capture n'apparaissait dans le fil QUE si le modèle recopiait 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 — la capture existait
pourtant bien sur le disque.

L'événement d'outil porte désormais le chemin de l'image et l'interface
la rend directement dans la bulle web_screenshot (cliquable pour la
loupe, comme les autres images du fil). Ce que le modèle en écrit
ensuite reste possible, mais n'est plus la condition de l'affichage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:38 +02:00

1328 lines
55 KiB
Go

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...)
}
// 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 = "</think>"
// 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 <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. 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.
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// 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(reasoningExplicitlyOff(chatCfg["REASONING"]), 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
// 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 <think> 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",
"messages": 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},
}
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
}
// 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 « </think> » (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 <think> block. Stream `content` LIVE as
// the answer, holding back only a short tail that could be the
// start of a literal "</think>". A reasoning-aware backend
// (llama.cpp with --reasoning-format, Nathan's fork) strips the
// think tags server-side, so </think> never appears in content
// and the whole answer streams straight through — including
// when the model answers WITHOUT thinking (no reasoning_content,
// no </think>), 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 </think> below.
thinkTail.WriteString(ch.Delta.Content)
s := thinkTail.String()
if i := strings.Index(s, thinkClose); i >= 0 {
// Vanilla inline think: reasoning before </think>, 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 </think> yet: stream as content, holding back a tail
// that could be a partial "</think>". 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 </think>): 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
}