diff --git a/chat.go b/chat.go index b132f42..88c7b2d 100644 --- a/chat.go +++ b/chat.go @@ -73,6 +73,13 @@ func cmdChat(args []string) error { fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) case ev.Stats != nil: stats = ev.Stats + case ev.DropReasoning: + // Le tour a « pensé sans agir » : on relance. Impossible d'effacer le + // texte déjà imprimé en terminal — on referme juste la ligne reasoning. + if inReason { + fmt.Print("\n") + inReason = false + } case ev.ToolUsed != nil: if ev.ToolUsed.Done || ev.ToolUsed.Typing { break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule diff --git a/llm.go b/llm.go index 8591cea..a8ad80f 100644 --- a/llm.go +++ b/llm.go @@ -209,13 +209,18 @@ func EnabledTools(caps Caps) []Tool { } // StreamEvent is what a ChatCallback receives for each piece of streamed output. -// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err} is set per call. +// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set +// per call. type StreamEvent struct { Content string Reasoning string ToolUsed *ToolUsedEvent Stats *StatsEvent 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 } type ToolUsedEvent struct { Name string @@ -346,6 +351,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // several times, it's stuck — break instead of spinning to the iteration cap. lastSig := "" repeatSig := 0 + // 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 for iter := 0; iter < 8; iter++ { payload := map[string]any{ "model": "jean", @@ -695,6 +705,21 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // (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: "Tu as réfléchi mais tu n'as ni appelé d'outil ni répondu. Agis MAINTENANT : appelle directement l'outil approprié (par ex. mem_search/mem_read/bash), ou donne ta réponse finale si tu as déjà l'information. N'explique pas, agis.", + }) + continue + } cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) } return extra, nil diff --git a/tools.go b/tools.go index c149948..fa38279 100644 --- a/tools.go +++ b/tools.go @@ -30,44 +30,23 @@ func baseSystemPrompt(caps Caps) string { if !caps.Agent { return "" } - cwd, _ := os.Getwd() - var b strings.Builder - b.WriteString("You are an expert coding assistant operating inside jean, a coding agent harness. You help users by reading files, executing commands, editing code, writing new files, and leveraging persistent memory to remember things across sessions.\n\n") - - b.WriteString("Available tools:\n") + // Prompt VOLONTAIREMENT court. Un préambule verbeux (longue liste de + // « guidelines », surtout des méta-instructions sur la réflexion) fait + // sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur + // puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts » + // mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler. + b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools.\n\n") + b.WriteString("Tools:\n") for _, l := range []string{ - "bash: Execute a shell command on this machine (ls, grep, find, cat, sed, read logs, run scripts…) and return stdout, stderr and the exit code. This is your real access to the system.", - "edit: Modify a file on disk by exact replacement (old → new; old must match exactly once). Use this to patch a file instead of rewriting it whole.", - "mem_search: Search your memory (Markdown pages under MEMORY/). Returns a ranked list of {file, title, snippet}.", - "mem_read: Read a memory page (Markdown file), line-numbered, with optional offset/limit.", - "mem_add: Create a new memory page (one topic per page, descriptive kebab-case name, first line is a short # title).", - "mem_edit: Edit a memory page by exact replacement (old → new; old must match exactly once).", + "bash — run a shell command on this machine (inspect files, processes, logs, run scripts).", + "edit — patch a file by exact replacement (old → new, old must be unique).", + "mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.", } { b.WriteString("- " + l + "\n") } - - b.WriteString("\nGuidelines:\n") - for _, l := range []string{ - "Reason briefly (1-2 sentences) then ACT. As soon as an action is possible, call the tool immediately — don't announce it, do it.", - "Never repeat the same reasoning step or the same tool call twice. If you hesitate (\"maybe I should check…\"), stop thinking and call the tool now.", - "Use bash for any system question (files, processes, network, OS) instead of assuming — you have real access, don't ask permission to inspect.", - "Use bash to read files (cat, sed -n); to change a file, use the edit tool (old → new) instead of rewriting it whole with bash.", - "Use mem_search FIRST whenever the user asks about something you might already know (preferences, ongoing projects, past decisions). Open the most relevant page with mem_read; snippets are hints, not the answer.", - "Save anything worth keeping across sessions with mem_add; update an existing page with mem_edit. Don't save ephemeral conversation state.", - "Prefer targeted, non-interactive commands; chain them with && when they depend on each other.", - "Avoid destructive commands (rm -rf, mkfs, dd…) unless the user explicitly asks.", - "If a command fails, read stderr and the exit code, then fix it — don't re-run the same command unchanged.", - "Never invent a tool: you only have bash, edit and the mem_* tools. Anything else (reading code, web access) goes through bash.", - "When the task is done, give a short final answer and stop. Don't chain useless tool calls.", - "Be concise in your responses.", - "Show file paths clearly when working with files.", - } { - b.WriteString("- " + l + "\n") - } - - b.WriteString("\nCurrent date: " + time.Now().Format("2006-01-02")) - b.WriteString("\nCurrent working directory: " + cwd) + b.WriteString("\nWhen the user asks about something you might already know (preferences, projects, past decisions), call mem_search first, then mem_read the most relevant page. For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, or answer if you already have the info. Never end your turn after only thinking. Be concise.\n") + b.WriteString("\nDate: " + time.Now().Format("2006-01-02")) return b.String() } diff --git a/ui/index.html b/ui/index.html index ef6f98d..25fa8a7 100644 --- a/ui/index.html +++ b/ui/index.html @@ -954,6 +954,14 @@ async function send(){ if(tu.name==='mem_add' || tu.name==='mem_edit') loadMem(); } } + if(d.drop_reasoning){ + // Le modèle a « pensé sans agir » : on relance le tour. Le + // raisonnement mort-né est retiré pour ne pas afficher un double bloc. + if(reasonEl){ + const i=turnCollapsibles.indexOf(reasonEl); if(i>=0) turnCollapsibles.splice(i,1); + reasonEl.remove(); reasonEl=null; fullReason=''; + } + } if(d.reasoning_content){ // Start a fresh buffer per bubble — otherwise a reasoning block that // follows a tool call re-renders ALL previously accumulated reasoning diff --git a/web.go b/web.go index 0a1bc92..4e4d16e 100644 --- a/web.go +++ b/web.go @@ -680,6 +680,9 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) if ev.Stats != nil { return emit(map[string]any{"stats": ev.Stats}) } + if ev.DropReasoning { + return emit(map[string]any{"drop_reasoning": true}) + } if ev.Reasoning != "" { return emit(map[string]any{"reasoning_content": ev.Reasoning}) }