From c744b744ee594f7a2a1274aa186e5367f2eef452 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 15 Oct 2026 08:40:59 +0200 Subject: [PATCH] =?UTF-8?q?Chat=20:=20l'ex=C3=A9cution=20des=20outils=20so?= =?UTF-8?q?rt=20de=20runChatLoop=20(execTool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot 6, étape 1, sans changement de comportement : le switch des outils de la boucle de chat (une cinquantaine d'outils) devient execTool, extrait tel quel ; seule la question « ask », qui émettait un événement, le rend désormais à l'appelant qui l'émet au même moment. C'est la frontière par laquelle une boucle de chat écrite en Rust fera exécuter ses outils par le Go tant qu'ils ne seront pas portés : API interne /internal/v1/tool/exec, même fonction, donc mêmes garde-fous (commande dangereuse refusée avant exécution, cible distante, écriture hors du dossier de travail) ; la coupure de la connexion interrompt la commande comme le bouton stop. Co-Authored-By: Claude Opus 5.5 --- internal/loki/llm_client.go | 401 +++++++++++++----------- internal/loki/web_internal.go | 2 + internal/loki/web_internal_tool.go | 53 ++++ internal/loki/web_internal_tool_test.go | 62 ++++ 4 files changed, 332 insertions(+), 186 deletions(-) create mode 100644 internal/loki/web_internal_tool.go create mode 100644 internal/loki/web_internal_tool_test.go diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 6faef72..c7d63ab 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -2360,192 +2360,10 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools extra = append(extra, toolMsg) continue } - switch tc.Function.Name { - case "see_image": - result, visionImg = toolSeeImage(label) - case "recall": - result = toolRecall(args) - case "recall_search": - result = toolRecallSearch(args) - case "tracker": - result = toolTracker(args) - 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, diffAdd = 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, diffAdd, diffDel = 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, diffAdd = 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, diffAdd, diffDel = 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, args) - case "git_diff": - result = toolGitDiff(ctx, args) - case "git_clone": - result = toolGitClone(ctx, args) - case "criteria": - result = toolCriteria(args, caps.Role == "verifier") - case "subagent": - result = toolSubagent(ctx, args, caps) - 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 - } - // Mode Code : « cmd & » laisse un process orphelin, sans sortie - // lisible ni moyen de l'arrêter — bash_bg est fait pour ça. - if caps.Code && trailingBackground(label) { - result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash." - 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_images": - result = capWebOutput(toolWebImages(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) - case "task_list": - result = toolTaskList(args) - case "task_create": - result = toolTaskCreate(args) - case "task_update": - result = toolTaskUpdate(args) - case "task_delete": - result = toolTaskDelete(args) - case "browser_open": - result = capCUOutput(toolCUOpen(args)) - case "browser_snapshot": - result = capCUOutput(toolCUSnapshot()) - case "browser_find": - result = capCUOutput(toolCUFind(args)) - case "browser_click": - result = capCUOutput(toolCUClick(args)) - case "browser_click_xy": - result = capCUOutput(toolCUClickXY(args)) - case "browser_type": - result = capCUOutput(toolCUType(args)) - case "browser_key": - result = capCUOutput(toolCUKey(args)) - case "browser_scroll": - result = capCUOutput(toolCUScroll(args)) - case "browser_screenshot": - result, visionImg = toolCUScreenshot() - default: - if isMCPTool(tc.Function.Name) { - result = mcpCall(tc.Function.Name, args) - } else { - result = "[erreur] outil inconnu: " + tc.Function.Name - } + ex := execTool(ctx, tc.Function.Name, args, label, caps) + result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg + if ex.Ask != nil { + cb(StreamEvent{Ask: ex.Ask}) } // Aperçu en direct du navigateur piloté (repris d'AJEAN) : une // capture après chaque action qui change la page, pour l'UI seule. @@ -2773,3 +2591,214 @@ func healthCheck() bool { io.Copy(io.Discard, resp.Body) return resp.StatusCode == 200 } + +// toolExecResult : ce que produit l'exécution d'un appel d'outil, hors +// événements d'interface (que l'appelant émet). +type toolExecResult struct { + Result string `json:"result"` + Diff []DiffLine `json:"diff,omitempty"` + Added int `json:"added,omitempty"` + Removed int `json:"removed,omitempty"` + VisionImg map[string]any `json:"vision_img,omitempty"` + Ask *AskEvent `json:"ask,omitempty"` +} + +// execTool exécute UN appel d'outil. Extrait tel quel de runChatLoop (lot 6 +// de la migration Rust) : c'est la frontière par laquelle une boucle de chat +// écrite en Rust fera exécuter ses outils par ce process, tant qu'ils n'auront +// pas été portés (API interne /internal/v1/tool/exec). label = toolCallLabel. +func execTool(ctx context.Context, name string, args map[string]any, label string, caps Caps) toolExecResult { + result := "" + var diff []DiffLine + var diffAdd, diffDel int + var visionImg map[string]any + var ask *AskEvent + switch name { + case "see_image": + result, visionImg = toolSeeImage(label) + case "recall": + result = toolRecall(args) + case "recall_search": + result = toolRecallSearch(args) + case "tracker": + result = toolTracker(args) + 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, diffAdd = 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, diffAdd, diffDel = 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, diffAdd = 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, diffAdd, diffDel = 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) + 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, args) + case "git_diff": + result = toolGitDiff(ctx, args) + case "git_clone": + result = toolGitClone(ctx, args) + case "criteria": + result = toolCriteria(args, caps.Role == "verifier") + case "subagent": + result = toolSubagent(ctx, args, caps) + 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 + } + // Mode Code : « cmd & » laisse un process orphelin, sans sortie + // lisible ni moyen de l'arrêter — bash_bg est fait pour ça. + if caps.Code && trailingBackground(label) { + result = "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash." + 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_images": + result = capWebOutput(toolWebImages(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) + case "task_list": + result = toolTaskList(args) + case "task_create": + result = toolTaskCreate(args) + case "task_update": + result = toolTaskUpdate(args) + case "task_delete": + result = toolTaskDelete(args) + case "browser_open": + result = capCUOutput(toolCUOpen(args)) + case "browser_snapshot": + result = capCUOutput(toolCUSnapshot()) + case "browser_find": + result = capCUOutput(toolCUFind(args)) + case "browser_click": + result = capCUOutput(toolCUClick(args)) + case "browser_click_xy": + result = capCUOutput(toolCUClickXY(args)) + case "browser_type": + result = capCUOutput(toolCUType(args)) + case "browser_key": + result = capCUOutput(toolCUKey(args)) + case "browser_scroll": + result = capCUOutput(toolCUScroll(args)) + case "browser_screenshot": + result, visionImg = toolCUScreenshot() + default: + if isMCPTool(name) { + result = mcpCall(name, args) + } else { + result = "[erreur] outil inconnu: " + name + } + } + return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, Ask: ask} +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index f321725..a2f231e 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -69,6 +69,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIBegin(w, r) case "/internal/v1/oai/end": handleInternalOAIEnd(w, r) + case "/internal/v1/tool/exec": + handleInternalToolExec(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_tool.go b/internal/loki/web_internal_tool.go new file mode 100644 index 0000000..e4fafb9 --- /dev/null +++ b/internal/loki/web_internal_tool.go @@ -0,0 +1,53 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" +) + +// API interne d'exécution des outils (lot 6 de la migration Rust). +// +// Une boucle de chat écrite en Rust fera exécuter ses appels d'outils par ce +// process, le temps de porter les outils eux-mêmes (lot 7) : exactement la +// fonction qu'emploie runChatLoop (execTool), donc mêmes garde-fous (commande +// dangereuse, écriture hors du dossier de travail, cible distante…) et mêmes +// résultats. +// +// POST /internal/v1/tool/exec +// {"name":"bash","args":{"command":"ls"},"caps":{"Agent":true,"Code":false,…}} +// → {"label":"ls","result":"…","diff":[…],"added":N,"removed":N,"vision_img":{…},"ask":{…}} +// +// Le contexte de la requête porte l'arrêt : si l'appelant coupe la connexion +// (bouton stop), la commande en cours est interrompue comme dans le Go. + +// internalToolMaxBody : les arguments d'un appel (contenu d'un fichier écrit +// par l'agent compris) restent très en dessous. +const internalToolMaxBody = 32 << 20 + +func handleInternalToolExec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + var in struct { + Name string `json:"name"` + Args map[string]any `json:"args"` + Caps Caps `json:"caps"` + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalToolMaxBody+1)) + if err != nil || len(raw) > internalToolMaxBody || json.Unmarshal(raw, &in) != nil || in.Name == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "appel d'outil illisible"}) + return + } + if in.Args == nil { + in.Args = map[string]any{} + } + label := toolCallLabel(in.Name, in.Args) + res := execTool(r.Context(), in.Name, in.Args, label, in.Caps) + sendJSON(w, http.StatusOK, struct { + Label string `json:"label"` + toolExecResult + }{label, res}) +} diff --git a/internal/loki/web_internal_tool_test.go b/internal/loki/web_internal_tool_test.go new file mode 100644 index 0000000..cd58edc --- /dev/null +++ b/internal/loki/web_internal_tool_test.go @@ -0,0 +1,62 @@ +package loki + +import ( + "encoding/json" + "strings" + "testing" +) + +func appelOutil(t *testing.T, body string) map[string]any { + t.Helper() + w := appelOAIInterne(t, "POST", "/internal/v1/tool/exec", body) + if w.Code != 200 { + t.Fatalf("code %d : %s", w.Code, w.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got +} + +// Un outil exécuté par l'API interne rend exactement ce que la boucle Go +// aurait obtenu : même fonction (execTool), mêmes garde-fous. +func TestOutilInterneMemesResultats(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + + got := appelOutil(t, `{"name":"outil_qui_n_existe_pas","args":{}}`) + if got["result"] != "[erreur] outil inconnu: outil_qui_n_existe_pas" { + t.Fatalf("outil inconnu : %v", got) + } + + // Commande catastrophique : refusée AVANT exécution, comme dans la boucle. + got = appelOutil(t, `{"name":"bash","args":{"command":"rm -rf /"},"caps":{"Agent":true}}`) + res, _ := got["result"].(string) + if want := refusedCommandResult(dangerousCommand("rm -rf /")); res != want { + t.Fatalf("garde-fou bash : %q, attendu %q", res, want) + } + if got["label"] != "rm -rf /" { + t.Fatalf("libellé : %v", got["label"]) + } + + // Question à l'utilisateur : l'événement remonte à l'appelant. + got = appelOutil(t, `{"name":"ask","args":{"question":"On continue ?","options":["oui","non"]},"caps":{"Agent":true}}`) + ask, _ := got["ask"].(map[string]any) + if r, _ := got["result"].(string); strings.HasPrefix(r, "[erreur]") || ask["question"] != "On continue ?" { + t.Fatalf("ask : %v", got) + } +} + +func TestOutilInterneAppelIllisible(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + for _, body := range []string{"", "{", `{"args":{}}`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/exec", body); w.Code != 400 { + t.Fatalf("%q : %d", body, w.Code) + } + } + if w := appelOAIInterne(t, "GET", "/internal/v1/tool/exec", ""); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +}