From dfafca6fb247b52fcdb2cf1417e95cd64e8db3d3 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:36:01 +0200 Subject: [PATCH] =?UTF-8?q?Mode=20Code=20:=20git=20sur=20le=20d=C3=A9p?= =?UTF-8?q?=C3=B4t=20clon=C3=A9,=20budget=20de=20build,=20sous-agents=20mo?= =?UTF-8?q?ins=20rigides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois trous relevés en comparant avec OpenFox. - git_status / git_diff tournaient à la racine de la discussion, qui n'est souvent pas un dépôt : git_clone range le projet dans un sous-dossier, et le vérificateur — dont la consigne commence par git_diff — tombait sur « not a git repository ». Ils visent maintenant le sous-dossier demandé (dir), la racine si c'est un dépôt, sinon l'unique dépôt présent ; plusieurs : on demande de choisir. - Budget d'outils triplé en mode Code : lire, éditer, compiler, relancer les tests dépasse vite 24 appels sans tourner en rond, et le troisième rappel (« n'appelle plus d'outil ») coupait le build au milieu. - Sous-agents : température 0.6 au lieu de 0 (en glouton, Qwen avec réflexion boucle vite ; le TEMP du preset l'emporte toujours), et seul le texte écrit après le dernier outil revient au builder. Co-Authored-By: Claude Opus 5.5 --- internal/loki/code_git.go | 64 +++++++++++++++++++++++++++++--- internal/loki/code_subagent.go | 12 +++++- internal/loki/code_tools_test.go | 25 +++++++++++++ internal/loki/llm_budget.go | 3 ++ internal/loki/llm_client.go | 8 +++- 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/internal/loki/code_git.go b/internal/loki/code_git.go index 40a4dc1..a2762c1 100644 --- a/internal/loki/code_git.go +++ b/internal/loki/code_git.go @@ -26,7 +26,9 @@ func gitStatusTool() Tool { Description: "git status of the working folder (branch + changed files, porcelain format).", Parameters: map[string]any{ "type": "object", - "properties": map[string]any{}, + "properties": map[string]any{ + "dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"}, + }, }, }, } @@ -42,6 +44,7 @@ func gitDiffTool() Tool { "type": "object", "properties": map[string]any{ "file": map[string]any{"type": "string", "description": "Limit the diff to this path"}, + "dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"}, }, }, }, @@ -66,15 +69,56 @@ func gitCloneTool() Tool { } } +// gitRepoDir : où lancer git_status / git_diff. La discussion n'est souvent PAS +// un dépôt : git_clone range le dépôt dans un sous-dossier, et git, lancé à la +// racine, échouait (« not a git repository ») — alors que le vérificateur a +// pour consigne de commencer par git_diff. Ordre : le sous-dossier demandé +// (dir), la racine si c'est un dépôt, sinon l'unique sous-dossier qui en est +// un. Plusieurs candidats : on demande de choisir. +func gitRepoDir(dir string) (string, error) { + base := agentCwd() + if dir = strings.TrimSpace(dir); dir != "" { + if strings.Contains(dir, "..") || filepath.IsAbs(dir) { + return "", fmt.Errorf("dossier invalide : %s", dir) + } + return filepath.Join(base, dir), nil + } + if _, err := os.Stat(filepath.Join(base, ".git")); err == nil { + return base, nil + } + entries, _ := os.ReadDir(base) + var repos []string + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(base, e.Name(), ".git")); err == nil { + repos = append(repos, e.Name()) + } + } + switch len(repos) { + case 0: + return base, nil // git dira lui-même « not a git repository » + case 1: + return filepath.Join(base, repos[0]), nil + } + return "", fmt.Errorf("plusieurs dépôts ici (%s) : précise dir", strings.Join(repos, ", ")) +} + // runGit exécute git dans le dossier de la discussion, sortie bornée. func runGit(ctx context.Context, timeout time.Duration, args ...string) string { + return runGitIn(ctx, agentCwd(), timeout, args...) +} + +// runGitIn exécute git dans dir, sortie bornée. +func runGitIn(ctx context.Context, dir string, timeout time.Duration, args ...string) string { if _, err := exec.LookPath("git"); err != nil { return "[erreur] git n'est pas installé sur cette machine" } cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(cctx, "git", args...) - cmd.Dir = agentCwd() + cmd.Dir = dir // Jamais d'invite interactive (mot de passe, hôte inconnu) : un process // serveur n'a personne pour y répondre, il faut échouer vite et le dire. cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes") @@ -95,24 +139,32 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string { return s } -func toolGitStatus(ctx context.Context) string { - return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch") +func toolGitStatus(ctx context.Context, args map[string]any) string { + dir, err := gitRepoDir(str(args["dir"])) + if err != nil { + return "[erreur] " + err.Error() + } + return runGitIn(ctx, dir, 20*time.Second, "status", "--porcelain=v1", "--branch") } func toolGitDiff(ctx context.Context, args map[string]any) string { + dir, err := gitRepoDir(str(args["dir"])) + if err != nil { + return "[erreur] " + err.Error() + } // Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail. // HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple. gitArgs := []string{"diff", "HEAD"} if file, _ := args["file"].(string); strings.TrimSpace(file) != "" { gitArgs = append(gitArgs, "--", file) } - out := runGit(ctx, 30*time.Second, gitArgs...) + out := runGitIn(ctx, dir, 30*time.Second, gitArgs...) if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") { fallback := []string{"diff"} if file, _ := args["file"].(string); strings.TrimSpace(file) != "" { fallback = append(fallback, "--", file) } - out = runGit(ctx, 30*time.Second, fallback...) + out = runGitIn(ctx, dir, 30*time.Second, fallback...) } return out } diff --git a/internal/loki/code_subagent.go b/internal/loki/code_subagent.go index 7371659..b6cc77a 100644 --- a/internal/loki/code_subagent.go +++ b/internal/loki/code_subagent.go @@ -30,6 +30,9 @@ import ( // permettrait au builder de se décerner son propre satisfecit. var subagentRoles = []string{"explorer", "code-reviewer", "planner"} +// subagentTemperature : voir toolSubagent. +const subagentTemperature = 0.6 + func isSubagentRole(r string) bool { for _, x := range subagentRoles { if x == r { @@ -102,7 +105,14 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string var out strings.Builder // Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit // la bulle de l'outil `subagent` et son rapport, pas les dix lectures. - if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool { + // Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en + // boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte. + // Seul le texte écrit APRÈS le dernier outil revient au builder : la + // narration entre deux lectures n'est pas le rapport. + if _, err := runChat(ctx, msgs, subagentTemperature, caps, func(ev StreamEvent) bool { + if ev.ToolUsed != nil { + out.Reset() + } if ev.Content != "" { out.WriteString(ev.Content) } diff --git a/internal/loki/code_tools_test.go b/internal/loki/code_tools_test.go index d2e5563..712cad7 100644 --- a/internal/loki/code_tools_test.go +++ b/internal/loki/code_tools_test.go @@ -157,3 +157,28 @@ func TestEditCRLFMultiligne(t *testing.T) { t.Fatalf("résultat inattendu : %q", b) } } + +// git_status / git_diff trouvent le dépôt cloné dans un sous-dossier de la +// discussion, et demandent de choisir quand il y en a plusieurs. +func TestGitRepoDirDetecteLeClone(t *testing.T) { + ws := withWorkspace(t) + if d, err := gitRepoDir(""); err != nil || d != agentCwd() { + t.Fatalf("sans dépôt : %q, %v", d, err) + } + if err := os.MkdirAll(filepath.Join(agentCwd(), "projet", ".git"), 0o755); err != nil { + t.Fatal(err) + } + if d, err := gitRepoDir(""); err != nil || filepath.Base(d) != "projet" { + t.Fatalf("un clone : %q, %v (ws %s)", d, err, ws) + } + _ = os.MkdirAll(filepath.Join(agentCwd(), "autre", ".git"), 0o755) + if _, err := gitRepoDir(""); err == nil { + t.Fatal("deux dépôts : il fallait demander dir") + } + if d, err := gitRepoDir("autre"); err != nil || filepath.Base(d) != "autre" { + t.Fatalf("dir explicite : %q, %v", d, err) + } + if _, err := gitRepoDir("../dehors"); err == nil { + t.Fatal("dir hors de la discussion accepté") + } +} diff --git a/internal/loki/llm_budget.go b/internal/loki/llm_budget.go index af9a374..b4938bd 100644 --- a/internal/loki/llm_budget.go +++ b/internal/loki/llm_budget.go @@ -89,3 +89,6 @@ func logBudget(calls, budget, sent int) { fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n", calls, budget, sent) } + +// codeBudgetFactor : multiplicateur du palier en mode Code (voir runChat). +const codeBudgetFactor = 3 diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index e1ae97e..67323f5 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -959,6 +959,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // 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() + // Mode Code : lire, éditer, compiler, relancer les tests — un vrai tour de + // build dépasse vite 24 appels sans tourner en rond, et le troisième rappel + // (« n'appelle plus d'outil ») coupait le travail au milieu. Palier triplé. + if caps.Code { + budget *= codeBudgetFactor + } for iter := 0; ; iter++ { // Ajout en cours de réponse : entre deux étapes (après un appel d'outil ou // une relance), on injecte les messages mis en file par l'utilisateur. Pas @@ -1705,7 +1711,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps case "bash_tail": result = toolBashTail(args) case "git_status": - result = toolGitStatus(ctx) + result = toolGitStatus(ctx, args) case "git_diff": result = toolGitDiff(ctx, args) case "git_clone":