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) + } +}