mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Le builder délègue à un rôle en lecture seule (explorer, code-reviewer, planner) sans passer par /tool/exec : src/chat/subagent.rs porte toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu, verifier compris, tâche vide, récursion), contexte isolé servi par le Go (POST /internal/v1/turn/subagent : prompt du rôle + briefing machine, outils, capacités ; subagentView et subagentFraming partagés), tour imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du tour qui délègue, température 0,6, travail annexe (second slot, effacé à la fin), télémétrie à part. Seul le texte écrit après le dernier outil revient, rogné, préfixé du rôle, tronqué à 6000 runes. Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost sauf un nouvel appel à subagent ; les outils de la façade passent dans GoTurnHost::exec_tool_base, que seul le tour principal complète de subagent (le type du tour imbriqué ne se contient pas lui-même). Parité : journal tiré du Go contre un faux moteur scripté (parity_subagent_test.go → tests/parity_subagent/subagent.json : refus, réponse directe, rapport après deux outils, texte vide relancé, rapport tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle), rejoué par un tour complet de la façade (tests/lot6.rs) ; TestSubagentVueInterne côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
244 lines
8.1 KiB
Go
244 lines
8.1 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// API interne d'un tour de discussion tenu par la façade Rust (lot 6).
|
|
//
|
|
// La boucle, le journal et la persistance sont en Rust ; ce qui décrit le
|
|
// tour reste ici, le temps d'être porté (lot 7) : pièces jointes et vision,
|
|
// rôle du mode Code, prompt du preset, consignes du dépôt, contexte du
|
|
// projet, préambule d'agent, outils (MCP compris), puce « mode Code »,
|
|
// notification Web Push, et l'état par discussion que les outils lisent.
|
|
//
|
|
// POST /internal/v1/turn/start corps de /api/chat/send
|
|
// → {files, caps, turn_caps, content, model, code_hint}
|
|
// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools}
|
|
// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go)
|
|
// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés)
|
|
// POST /internal/v1/project/activate {"slug":…} → 204 (projet actif changé
|
|
// par la façade, qui a enregistré la discussion du projet quitté)
|
|
// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps}
|
|
// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier)
|
|
// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils)
|
|
// POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères,
|
|
// mode, résultats d'outils ;
|
|
// "files_only" : les fichiers)
|
|
|
|
const internalTurnMaxBody = 8 << 20
|
|
|
|
func readInternalJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", http.MethodPost)
|
|
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
|
return false
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, internalTurnMaxBody+1))
|
|
if err != nil || len(raw) > internalTurnMaxBody || json.Unmarshal(raw, v) != nil {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// handleInternalTurnStart : ce que StartTurn et EnqueueOrStart tirent de la
|
|
// requête avant de lancer (ou de mettre en file) le tour. caps : capacités
|
|
// demandées (file d'attente, préchauffage) ; turn_caps : celles du tour, rôle
|
|
// du mode Code figé ; content : le message utilisateur vu par le modèle.
|
|
func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) {
|
|
var body chatReq
|
|
if !readInternalJSON(w, r, &body) {
|
|
return
|
|
}
|
|
files := attachFiles(body.Files)
|
|
if strings.TrimSpace(body.Message) == "" && len(files) == 0 {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "message vide"})
|
|
return
|
|
}
|
|
rememberUserTZ(body.TZ)
|
|
caps := capsFromBody(body)
|
|
turnCaps := withTurnRole(caps, body.Message)
|
|
prompt := body.Message
|
|
if strings.TrimSpace(prompt) == "" {
|
|
prompt = "Prends-en connaissance."
|
|
}
|
|
if files == nil {
|
|
files = []attachInfo{}
|
|
}
|
|
sendJSON(w, http.StatusOK, map[string]any{
|
|
"files": files,
|
|
"caps": caps,
|
|
"turn_caps": turnCaps,
|
|
"content": userMessageContent(files, prompt),
|
|
"model": chatModelName(),
|
|
"code_hint": !turnCaps.Code && codeHintDue(body.Message),
|
|
})
|
|
}
|
|
|
|
// handleInternalTurnView : les pièces de turnView (assembleTurn) que la
|
|
// façade assemble autour de l'historique : head part devant les messages, puis
|
|
// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un
|
|
// message projet, ou posé en tête. Avec PROJ_SNAPSHOT et l'agent, la tête
|
|
// porte le bloc figé de la discussion (snap) et projsnap dit quoi en faire
|
|
// (facade_projsnap.go) ; dry : pour le préchauffage, rien à ranger.
|
|
func handleInternalTurnView(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Caps Caps `json:"caps"`
|
|
Snap *projSnapshot `json:"snap"`
|
|
Dry bool `json:"dry"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
caps := in.Caps
|
|
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
|
sendJSON(w, http.StatusOK, projSnapView(caps, in.Snap, in.Dry))
|
|
return
|
|
}
|
|
head := []Message{}
|
|
if caps.Agent {
|
|
if sp := readSysPrompt(); sp != "" {
|
|
head = append(head, Message{Role: "system", Content: sp})
|
|
}
|
|
if m, ok := codeInstructionsMessage(caps); ok {
|
|
head = append(head, m)
|
|
}
|
|
head = append(head, projectSystemMessages()...)
|
|
}
|
|
tools := EnabledTools(caps)
|
|
if tools == nil {
|
|
tools = []Tool{}
|
|
}
|
|
sendJSON(w, http.StatusOK, map[string]any{"head": head, "prefix": injectPrefix(caps, tools), "tools": tools})
|
|
}
|
|
|
|
// handleInternalTurnVerifier : ce que runVerifyPass envoie au moteur hors de
|
|
// l'historique — le prompt du rôle verifier suivi des faits de la machine, et
|
|
// le jeu d'outils fermé de la vérification. caps : celles du tour, rôle
|
|
// remplacé (le Go s'y fie pour laisser l'outil criteria marquer « passed »).
|
|
func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Caps Caps `json:"caps"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
vcaps := in.Caps
|
|
vcaps.Role = "verifier"
|
|
sendJSON(w, http.StatusOK, map[string]any{
|
|
"system": rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps),
|
|
"tools": EnabledTools(vcaps),
|
|
"caps": vcaps,
|
|
})
|
|
}
|
|
|
|
// handleInternalTurnSubagent : le contexte isolé d'un rôle délégué par
|
|
// l'outil subagent de la façade (lot 47) — prompt système, outils et
|
|
// capacités du rôle ; la tâche et son cadrage restent chez elle.
|
|
func handleInternalTurnSubagent(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Role string `json:"role"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if !isSubagentRole(in.Role) {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "rôle inconnu : " + in.Role})
|
|
return
|
|
}
|
|
caps, system := subagentView(in.Role)
|
|
sendJSON(w, http.StatusOK, map[string]any{
|
|
"system": system,
|
|
"tools": EnabledTools(caps),
|
|
"caps": caps,
|
|
})
|
|
}
|
|
|
|
// handleInternalProjectActivate : setActiveProject pour la façade (projet
|
|
// rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils).
|
|
func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Slug string `json:"slug"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if !projectExists(in.Slug) {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "projet introuvable"})
|
|
return
|
|
}
|
|
if err := setActiveProject(in.Slug); err != nil {
|
|
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Ms int64 `json:"ms"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if hasPushSubs() {
|
|
d := time.Duration(in.Ms) * time.Millisecond
|
|
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(d))
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleInternalConvActive : la façade a changé de discussion active. Les
|
|
// outils exécutés ici (dossier de la discussion, mode Code, critères) lisent
|
|
// l'identifiant en cache : il suit.
|
|
func handleInternalConvActive(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(in.ID) == "" {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"})
|
|
return
|
|
}
|
|
convActiveMu.Lock()
|
|
convActiveRemember(in.ID)
|
|
convActiveMu.Unlock()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleInternalConvDropped : ce qui part avec une discussion supprimée par
|
|
// la façade (mêmes effets que la fin de convDelete).
|
|
func handleInternalConvDropped(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
ID string `json:"id"`
|
|
// FilesOnly : seulement les fichiers (Reset vide la discussion, il ne
|
|
// la supprime pas).
|
|
FilesOnly bool `json:"files_only"`
|
|
}
|
|
if !readInternalJSON(w, r, &in) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(in.ID) == "" {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"})
|
|
return
|
|
}
|
|
if in.FilesOnly {
|
|
dropConvFiles(in.ID)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
stopConvJobs(in.ID)
|
|
critDrop(in.ID)
|
|
dropConvMode(in.ID)
|
|
dropConvFiles(in.ID)
|
|
deleteToolResultsFor(in.ID)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|