API interne : suites d'un outil et archive recall pour le tour tenu par Rust

/internal/v1/tool/exec rend aussi ce que la boucle Go tire du résultat
après l'exécution, et qui demande le disque ou le navigateur
(toolStepExtras) : capture de web_screenshot pour la bulle, aperçu du
navigateur après une action qui change la page (jamais après une erreur),
message qui relaie l'image au modèle (capture avec la vision, see_image).

/internal/v1/recall/archive range un bloc verbatim pour l'outil recall :
son numéro vient de la séquence bbolt, seul ce process peut le donner. 423
si la mémoire est verrouillée, rien d'écrit en clair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 10:34:14 +00:00
1 parent 73bbf92f69
commit e114ba3800
5 files changed
+140 -2

No files matched your search

+2
View File
@@ -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)
}
+48
View File
@@ -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})
}
+29
View File
@@ -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)
}
}
}
+37 -2
View File
@@ -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
}
+24
View File
@@ -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)
}
}