Files
Loki/internal/loki/code_git.go
T
MichaelandClaude Opus 5.5 dfafca6fb2 Mode Code : git sur le dépôt cloné, budget de build, sous-agents moins rigides
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 <noreply@anthropic.com>
2026-10-02 23:36:01 +02:00

202 lines
6.7 KiB
Go

package loki
// code_git.go — outils git natifs du mode code : git_status, git_diff,
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
const gitMaxOutput = 8000
func gitStatusTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_status",
Description: "git status of the working folder (branch + changed files, porcelain format).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
},
},
}
}
func gitDiffTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_diff",
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
Parameters: map[string]any{
"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)"},
},
},
},
}
}
func gitCloneTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_clone",
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "Repository URL"},
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
},
"required": []string{"url"},
},
},
}
}
// 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 = 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")
out, err := cmd.CombinedOutput()
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
if cctx.Err() == context.DeadlineExceeded {
return "[timeout] git " + strings.Join(args, " ")
}
if err != nil {
if s == "" {
s = err.Error()
}
return "[erreur] " + s
}
if s == "" {
s = "(aucune sortie)"
}
return s
}
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 := 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 = runGitIn(ctx, dir, 30*time.Second, fallback...)
}
return out
}
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
// exotiques (file://, ext:: qui exécute une commande arbitraire).
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
func toolGitClone(ctx context.Context, args map[string]any) string {
url, _ := args["url"].(string)
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
}
dir, _ := args["dir"].(string)
dir = strings.TrimSpace(dir)
if dir == "" {
base := filepath.Base(strings.TrimSuffix(url, ".git"))
dir = base
}
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "[erreur] dossier cible invalide"
}
target := filepath.Join(agentCwd(), dir)
if _, err := os.Stat(target); err == nil {
return "[erreur] le dossier " + dir + " existe déjà"
}
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
return out
}
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
}