diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 1a7a869..df3a83d 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -83,6 +83,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalLeaseRelease(w, r) case "/internal/v1/engine/stop": handleInternalEngineStop(w, r) + case "/internal/v1/recall/archive": + handleInternalRecallArchive(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_recall.go b/internal/loki/web_internal_recall.go new file mode 100644 index 0000000..a811693 --- /dev/null +++ b/internal/loki/web_internal_recall.go @@ -0,0 +1,48 @@ +package loki + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +// API interne de l'archive recall (lot 6 de la migration Rust). +// +// La compaction range VERBATIM les gros blocs du torse qu'elle résume, pour +// que l'outil recall puisse les rendre plus tard. Le numéro d'un bloc vient de +// la séquence bbolt du bucket : seul ce process peut le donner. Une +// compaction faite par la façade fait donc archiver ici. +// +// POST /internal/v1/recall/archive {"label":…,"role":…,"content":…} +// → 200 {"id":"r7"} · 423 mémoire verrouillée (rien d'écrit en clair) + +const internalRecallMaxBody = 32 << 20 + +func handleInternalRecallArchive(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 { + Label string `json:"label"` + Role string `json:"role"` + Content string `json:"content"` + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalRecallMaxBody+1)) + if err != nil || len(raw) > internalRecallMaxBody || json.Unmarshal(raw, &in) != nil || in.Content == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "bloc illisible"}) + return + } + id, err := archiveRecallBlock(in.Label, in.Role, in.Content) + if errors.Is(err, errMemLocked) { + sendJSON(w, http.StatusLocked, map[string]any{"error": err.Error()}) + return + } + if err != nil { + sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"id": id}) +} diff --git a/internal/loki/web_internal_recall_test.go b/internal/loki/web_internal_recall_test.go new file mode 100644 index 0000000..63ec8cd --- /dev/null +++ b/internal/loki/web_internal_recall_test.go @@ -0,0 +1,29 @@ +package loki + +import ( + "strings" + "testing" +) + +// Un bloc archivé par la façade se relit par l'outil recall, comme s'il avait +// été archivé par la compaction du Go. +func TestRecallInterneArchive(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + w := appelOAIInterne(t, "POST", "/internal/v1/recall/archive", `{"label":"bash ls","role":"tool","content":"a.go\nb.go"}`) + if w.Code != 200 { + t.Fatalf("archive : %d %s", w.Code, w.Body.String()) + } + id, _ := leaseJSON(t, w.Body.String())["id"].(string) + if !strings.HasPrefix(id, "r") { + t.Fatalf("id : %q", id) + } + if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "a.go\nb.go") || !strings.Contains(got, "bash ls") { + t.Fatalf("recall : %q", got) + } + for _, body := range []string{"", "{", `{"label":"x"}`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/recall/archive", body); w.Code != 400 { + t.Fatalf("%q : %d", body, w.Code) + } + } +} diff --git a/internal/loki/web_internal_tool.go b/internal/loki/web_internal_tool.go index e4fafb9..edaae25 100644 --- a/internal/loki/web_internal_tool.go +++ b/internal/loki/web_internal_tool.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" ) // API interne d'exécution des outils (lot 6 de la migration Rust). @@ -16,7 +17,14 @@ import ( // // 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":{…}} +// → {"label":"ls","result":"…","diff":[…],"added":N,"removed":N,"vision_img":{…},"ask":{…}, +// "image":"…","shot":"…","relay":{…}} +// +// image, shot et relay sont ce que la boucle Go tire du résultat APRÈS +// l'exécution, et qui demande le disque ou le navigateur (toolStepExtras) : +// la capture à montrer dans la bulle, l'aperçu en direct du navigateur piloté, +// et le message `user` qui porte l'image au modèle (capture, see_image) — +// éphémère, à ne jamais ranger dans l'historique. // // 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. @@ -46,8 +54,35 @@ func handleInternalToolExec(w http.ResponseWriter, r *http.Request) { } label := toolCallLabel(in.Name, in.Args) res := execTool(r.Context(), in.Name, in.Args, label, in.Caps) + image, shot, relay := toolStepExtras(in.Name, label, res) sendJSON(w, http.StatusOK, struct { Label string `json:"label"` toolExecResult - }{label, res}) + Image string `json:"image,omitempty"` + Shot string `json:"shot,omitempty"` + Relay *Message `json:"relay,omitempty"` + }{label, res, image, shot, relay}) +} + +// toolStepExtras : ce que runChatLoop tire d'un résultat d'outil après +// l'exécution — capture de web_screenshot pour la bulle, aperçu du navigateur +// après une action qui change la page (jamais après une erreur), image relayée +// au modèle (capture avec la vision active, ou see_image réussi). +func toolStepExtras(name, label string, res toolExecResult) (image, shot string, relay *Message) { + if !strings.HasPrefix(res.Result, "[erreur]") && cuAutoShotTool(name) { + shot = cuAutoShot() + } + if name == "web_screenshot" { + image = capturedRelPath(res.Result) + if image != "" { + if m, ok := screenshotImageMessage(image); ok { + relay = &m + } + } + } + if res.VisionImg != nil { + m := seeImageMessage(label, res.VisionImg) + relay = &m + } + return image, shot, relay } diff --git a/internal/loki/web_internal_tool_test.go b/internal/loki/web_internal_tool_test.go index cd58edc..a7efc80 100644 --- a/internal/loki/web_internal_tool_test.go +++ b/internal/loki/web_internal_tool_test.go @@ -60,3 +60,27 @@ func TestOutilInterneAppelIllisible(t *testing.T) { t.Fatalf("GET : %d", w.Code) } } + +// Ce que la boucle tire du résultat après l'exécution : capture de la bulle, +// image relayée au modèle — rien après une erreur. +func TestOutilInterneSuitesDuResultat(t *testing.T) { + testHome(t) + image, shot, relay := toolStepExtras("web_screenshot", "", toolExecResult{ + Result: "Capture : ![page](/api/chat/image?path=captures/a.png)", + }) + if image != "captures/a.png" || shot != "" || relay != nil { + t.Fatalf("capture : %q %q %v (vision coupée : pas de relais)", image, shot, relay) + } + img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AA=="}} + _, _, relay = toolStepExtras("see_image", "photo.png", toolExecResult{Result: "ok", VisionImg: img}) + if relay == nil || relay.Role != "user" { + t.Fatalf("see_image : %v", relay) + } + b, _ := json.Marshal(relay) + if !strings.Contains(string(b), "Image demandée (photo.png)") || !strings.Contains(string(b), "data:image/png") { + t.Fatalf("relais : %s", b) + } + if _, shot, _ := toolStepExtras("browser_click", "x", toolExecResult{Result: "[erreur] rien"}); shot != "" { + t.Fatalf("aperçu après une erreur : %q", shot) + } +}