mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
Lot 47 : outil subagent du mode Code exécuté par la façade
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
This commit is contained in:
16 files changed
+1846
-73
No files matched your search
@@ -0,0 +1,115 @@
|
||||
# Lot 47 — Outil subagent du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le builder peut déléguer une question bornée à un rôle en
|
||||
lecture seule :
|
||||
|
||||
- `explorer` cartographie le code ;
|
||||
- `code-reviewer` relit un changement ;
|
||||
- `planner` découpe un travail et pose les critères d'acceptation.
|
||||
|
||||
Le rôle travaille dans son propre contexte : le prompt du rôle, le
|
||||
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
|
||||
rangée ni affichée. Seul son texte final revient au builder, comme
|
||||
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
|
||||
évite de payer dix lectures de fichiers dans le fil principal.
|
||||
|
||||
La façade tient le tour du builder et sa passe de vérification, mais
|
||||
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
|
||||
tourner un `runChat` à lui.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
|
||||
|
||||
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
|
||||
même ordre, avec les mêmes textes :
|
||||
|
||||
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
|
||||
- tâche vide ;
|
||||
- appel depuis un tour qui n'est ni le fil principal ni le builder
|
||||
(garde-fou anti-récursion).
|
||||
|
||||
Le contexte du rôle vient du Go
|
||||
(`POST /internal/v1/turn/subagent {role}`), qui rend :
|
||||
|
||||
- le prompt du rôle suivi du briefing machine ;
|
||||
- les outils du rôle ;
|
||||
- ses capacités (agent, Code, mémoire coupée).
|
||||
|
||||
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
|
||||
|
||||
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
|
||||
vérification :
|
||||
|
||||
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
|
||||
sont ceux du tour qui délègue (`SubTurn`) ;
|
||||
- la température est 0,6 (le TEMP du preset l'emporte) ;
|
||||
- la tâche porte le cadrage du rôle :
|
||||
- planner : critères et étapes, sans demander confirmation ;
|
||||
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
|
||||
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
|
||||
effacé à la fin pour que le builder reparte du cache RAM ;
|
||||
- la télémétrie le compte à part (`subagent`) ;
|
||||
- les critères posés par le planner s'affichent comme ceux du fil.
|
||||
|
||||
Le rapport se construit ainsi :
|
||||
|
||||
- seul le texte écrit après le dernier outil compte ;
|
||||
- il est rogné de ses blancs, puis préfixé du rôle ;
|
||||
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
|
||||
- vide : « n'a rien répondu » ;
|
||||
- erreur du tour : « sous-agent <rôle> : <erreur> ».
|
||||
|
||||
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
|
||||
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
|
||||
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
|
||||
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
|
||||
imbriqué ne se contient pas lui-même.
|
||||
|
||||
Le tour du builder et ses relances par la boucle du contrat prêtent leur
|
||||
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_subagent_test.go` tire du Go un journal
|
||||
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
|
||||
vues des trois rôles sont rangées avec lui.
|
||||
|
||||
Pour chaque tour délégué, le journal garde chaque requête du rôle :
|
||||
|
||||
- système identique à la vue ;
|
||||
- message de la tâche cadrée ;
|
||||
- température ;
|
||||
- outils ;
|
||||
- nombre de messages.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- les refus :
|
||||
- rôle inconnu, `verifier`, rôle absent ;
|
||||
- tâche vide ou absente ;
|
||||
- récursion depuis un rôle ou depuis la vérification ;
|
||||
- les tours :
|
||||
- réponse directe rognée ;
|
||||
- rapport après deux outils, narration écartée ;
|
||||
- texte vide relancé puis remplacé, comme le fait le tour ;
|
||||
- rien après l'outil ;
|
||||
- rapport tronqué ;
|
||||
- cadrage du planner ;
|
||||
- moteur qui refuse.
|
||||
|
||||
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
|
||||
|
||||
- les refus par `precheck` ;
|
||||
- les tours par un tour complet de la façade qui délègue, contre le même
|
||||
script.
|
||||
|
||||
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
|
||||
annexe ouvert puis fermé.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
|
||||
- tests unitaires : refus et leur ordre, rapports, cadrages.
|
||||
@@ -90,17 +90,10 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
caps, system := subagentView(role)
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: task + subagentFraming(role)},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Le sous-agent occupe le slot de la conversation du builder : une fois son
|
||||
@@ -133,3 +126,21 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
|
||||
// subagentView : les capacités et le prompt système d'un rôle délégué —
|
||||
// servis aussi à la façade (/internal/v1/turn/subagent, lot 47).
|
||||
func subagentView(role string) (Caps, string) {
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
return caps, rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)
|
||||
}
|
||||
|
||||
// subagentFraming : le cadrage ajouté à la tâche. Le rôle ne voit ni la
|
||||
// discussion ni l'utilisateur. Son prompt de rôle parle à un humain
|
||||
// (« termine en demandant confirmation ») ; ici son interlocuteur est le
|
||||
// builder, et sa réponse est un résultat d'outil.
|
||||
func subagentFraming(role string) string {
|
||||
if role == "planner" {
|
||||
return "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
return "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -54,3 +55,31 @@ func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lot 47 : la façade demande au Go le contexte isolé d'un rôle délégué —
|
||||
// le même prompt système, les mêmes outils et capacités que toolSubagent.
|
||||
func TestSubagentVueInterne(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", `{"role":"planner"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
System string `json:"system"`
|
||||
Tools []Tool `json:"tools"`
|
||||
Caps Caps `json:"caps"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps, system := subagentView("planner")
|
||||
if v.System != system || v.Caps != caps || len(v.Tools) != len(EnabledTools(caps)) {
|
||||
t.Fatalf("vue : %+v", v.Caps)
|
||||
}
|
||||
for _, body := range []string{`{"role":"verifier"}`, `{"role":""}`, `{`} {
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", body); w.Code != 400 {
|
||||
t.Fatalf("%s : %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parity_subagent_test.go — journal de référence de l'outil subagent du mode
|
||||
// Code (lot 47 de la migration Rust) : refus avant tout appel au moteur, puis
|
||||
// des tours délégués contre un faux moteur scripté (rapport après le dernier
|
||||
// outil, texte vide, rapport tronqué, refus du moteur, cadrage du planner).
|
||||
// Chaque étape garde ce que le rôle a envoyé au moteur (système identique à
|
||||
// la vue du rôle, message de la tâche, température, outils). Les vues des
|
||||
// rôles (/internal/v1/turn/subagent) sont rangées avec le journal.
|
||||
//
|
||||
// Rangé dans rust/loki-rs/tests/parity_subagent/subagent.json ; le test
|
||||
// subagent_comme_le_go de tests/lot6.rs le rejoue par un tour de la façade.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSubagent
|
||||
|
||||
const paritySubagentFile = "../../rust/loki-rs/tests/parity_subagent/subagent.json"
|
||||
|
||||
// Une réponse scriptée du faux moteur.
|
||||
type paritySubReply struct {
|
||||
Tool string `json:"tool,omitempty"` // appel d'outil (arguments : Args)
|
||||
Args string `json:"args,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Status int `json:"status,omitempty"` // refus du moteur (corps : Content)
|
||||
}
|
||||
|
||||
type paritySubRequest struct {
|
||||
SystemIsView bool `json:"system_is_view"`
|
||||
User string `json:"user"`
|
||||
Temperature any `json:"temperature"`
|
||||
Tools []string `json:"tools"`
|
||||
Messages int `json:"messages"`
|
||||
}
|
||||
|
||||
type paritySubStep struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"args"`
|
||||
ParentRole string `json:"parent_role,omitempty"`
|
||||
Replies []paritySubReply `json:"replies,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Requests []paritySubRequest `json:"requests"`
|
||||
}
|
||||
|
||||
func paritySubSSE(r paritySubReply) string {
|
||||
chunk := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return "data: " + string(b) + "\n\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
if r.Tool != "" {
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{
|
||||
"index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args},
|
||||
}}}}}}))
|
||||
if r.Content != "" {
|
||||
// Texte écrit avant l'appel : la narration, pas le rapport.
|
||||
b.Reset()
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}}))
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{
|
||||
"index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args},
|
||||
}}}}}}))
|
||||
}
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}}))
|
||||
} else {
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}}))
|
||||
b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}, "usage": map[string]any{"prompt_tokens": 40, "completion_tokens": 2}}))
|
||||
}
|
||||
b.WriteString("data: [DONE]\n\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestPariteSubagent(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
convEnsureActive()
|
||||
_ = os.WriteFile(filepath.Join(ws, "cache.go"), []byte("package x\n// cache LRU\n"), 0o644)
|
||||
|
||||
var mu sync.Mutex
|
||||
var queue []paritySubReply
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
var rep paritySubReply
|
||||
if len(queue) > 0 {
|
||||
rep, queue = queue[0], queue[1:]
|
||||
}
|
||||
mu.Unlock()
|
||||
if rep.Status != 0 {
|
||||
w.WriteHeader(rep.Status)
|
||||
_, _ = w.Write([]byte(rep.Content))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(paritySubSSE(rep)))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
views := map[string]any{}
|
||||
for _, role := range subagentRoles {
|
||||
caps, system := subagentView(role)
|
||||
views[role] = map[string]any{"system": system, "tools": EnabledTools(caps), "caps": caps}
|
||||
}
|
||||
|
||||
var steps []paritySubStep
|
||||
run := func(st paritySubStep) {
|
||||
mu.Lock()
|
||||
queue, bodies = append([]paritySubReply(nil), st.Replies...), nil
|
||||
mu.Unlock()
|
||||
parent := Caps{Agent: true, Code: true, Role: st.ParentRole}
|
||||
st.Result = toolSubagent(context.Background(), st.Args, parent)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
st.Requests = []paritySubRequest{}
|
||||
for _, b := range bodies {
|
||||
msgs, _ := b["messages"].([]any)
|
||||
req := paritySubRequest{Temperature: b["temperature"], Messages: len(msgs), Tools: []string{}}
|
||||
if len(msgs) >= 2 {
|
||||
sys, _ := msgs[0].(map[string]any)["content"].(string)
|
||||
role, _ := st.Args["role"].(string)
|
||||
_, want := subagentView(strings.TrimSpace(role))
|
||||
req.SystemIsView = sys == want
|
||||
req.User, _ = msgs[1].(map[string]any)["content"].(string)
|
||||
}
|
||||
tools, _ := b["tools"].([]any)
|
||||
for _, tl := range tools {
|
||||
f, _ := tl.(map[string]any)["function"].(map[string]any)
|
||||
n, _ := f["name"].(string)
|
||||
req.Tools = append(req.Tools, n)
|
||||
}
|
||||
st.Requests = append(st.Requests, req)
|
||||
}
|
||||
steps = append(steps, st)
|
||||
}
|
||||
a := func(role, task string) map[string]any { return map[string]any{"role": role, "task": task} }
|
||||
|
||||
run(paritySubStep{Name: "rôle inconnu", Args: a("architecte", "x")})
|
||||
run(paritySubStep{Name: "verifier refusé", Args: a("verifier", "x")})
|
||||
run(paritySubStep{Name: "sans rôle", Args: map[string]any{"task": "x"}})
|
||||
run(paritySubStep{Name: "tâche vide", Args: a("explorer", " ")})
|
||||
run(paritySubStep{Name: "tâche absente", Args: map[string]any{"role": "planner"}})
|
||||
run(paritySubStep{Name: "pas de récursion", Args: a("explorer", "x"), ParentRole: "explorer"})
|
||||
run(paritySubStep{Name: "vérificateur qui délègue", Args: a("planner", "x"), ParentRole: "verifier"})
|
||||
run(paritySubStep{Name: "réponse directe", Args: a("explorer", " Où est le cache ? "),
|
||||
Replies: []paritySubReply{{Content: " Dans cache.go:2. \n"}}})
|
||||
run(paritySubStep{Name: "rapport après le dernier outil", Args: a(" code-reviewer ", "Relis cache.go"),
|
||||
Replies: []paritySubReply{
|
||||
{Tool: "grep", Args: `{"pattern":"cache"}`, Content: "Je cherche d'abord."},
|
||||
{Tool: "read", Args: `{"path":"cache.go"}`},
|
||||
{Content: "RAS : cache.go:2 documente le LRU."},
|
||||
}})
|
||||
run(paritySubStep{Name: "rien répondu", Args: a("explorer", "Vide ?"),
|
||||
Replies: []paritySubReply{{Content: " "}}})
|
||||
run(paritySubStep{Name: "rien après l'outil", Args: a("explorer", "Encore ?"),
|
||||
Replies: []paritySubReply{{Tool: "grep", Args: `{"pattern":"x"}`, Content: "narration"}, {Content: ""}}})
|
||||
run(paritySubStep{Name: "rapport tronqué", Args: a("explorer", "Tout ?"),
|
||||
Replies: []paritySubReply{{Content: strings.Repeat("é", 6100)}}})
|
||||
run(paritySubStep{Name: "cadrage du planner", Args: a("planner", "Découpe le chantier"),
|
||||
Replies: []paritySubReply{{Content: "1. lire\n2. écrire"}}})
|
||||
run(paritySubStep{Name: "moteur qui refuse", Args: a("explorer", "Panne ?"),
|
||||
Replies: []paritySubReply{{Status: 400, Content: `{"error":{"message":"requête refusée"}}`}}})
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(map[string]any{"views": views, "steps": steps}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bytes.ReplaceAll(buf.Bytes(), []byte(ws), []byte("{{WS}}"))
|
||||
if h := os.Getenv("LOKI_HOME"); h != "" {
|
||||
got = bytes.ReplaceAll(got, []byte(h), []byte("{{HOME}}"))
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(paritySubagentFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paritySubagentFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(paritySubagentFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("subagent ne produit plus le journal rangé dans %s.\n"+
|
||||
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", paritySubagentFile)
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,8 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalTurnDone(w, r)
|
||||
case "/internal/v1/turn/verifier":
|
||||
handleInternalTurnVerifier(w, r)
|
||||
case "/internal/v1/turn/subagent":
|
||||
handleInternalTurnSubagent(w, r)
|
||||
case "/internal/v1/project/activate":
|
||||
handleInternalProjectActivate(w, r)
|
||||
case "/internal/v1/chat/holder":
|
||||
|
||||
@@ -137,6 +137,28 @@ func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -1018,12 +1018,19 @@ impl ChatService {
|
||||
queued: Box::new(move || me.drain_queued(epoch)),
|
||||
deltas: {
|
||||
let me = self.clone();
|
||||
Box::new(move |d| me.push_delta(d))
|
||||
Arc::new(move |d| me.push_delta(d))
|
||||
},
|
||||
perf_kind: super::perf::KIND_MAIN,
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: Some(super::subagent::SubTurn {
|
||||
ep: ep.clone(),
|
||||
reasoning: reasoning.clone(),
|
||||
echo: echo.clone(),
|
||||
patterns: self.shared.patterns.clone(),
|
||||
cancel: cancel.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Chaque événement devient un delta du journal.
|
||||
|
||||
@@ -415,7 +415,7 @@ pub struct GoTurnHost {
|
||||
pub queued: Box<dyn Fn() -> Vec<Message> + Send + Sync>,
|
||||
/// Un événement pour l'écran, hors du flux du tour (la liste des
|
||||
/// critères après l'outil `criteria`).
|
||||
pub deltas: Box<dyn Fn(super::journal::Delta) + Send + Sync>,
|
||||
pub deltas: Arc<dyn Fn(super::journal::Delta) + Send + Sync>,
|
||||
/// Nature des complétions pour la télémétrie : `main` (tour de la
|
||||
/// discussion), `verify` (passe de vérification du mode Code).
|
||||
pub perf_kind: &'static str,
|
||||
@@ -426,6 +426,9 @@ pub struct GoTurnHost {
|
||||
/// l'instantané posé par un rafraîchissement en cours de tour.
|
||||
pub projsnap: Option<SnapTurn>,
|
||||
pub snap_refreshed: Mutex<Option<ProjSnapshot>>,
|
||||
/// Ce que le tour prête à l'outil `subagent` (lot 47) ; `None` : l'outil
|
||||
/// passe par le Go.
|
||||
pub sub: Option<super::subagent::SubTurn>,
|
||||
}
|
||||
|
||||
/// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir
|
||||
@@ -614,63 +617,15 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnHost for GoTurnHost {
|
||||
async fn send(
|
||||
impl GoTurnHost {
|
||||
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
|
||||
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
|
||||
pub(crate) async fn exec_tool_base(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
// Moteur local : requête comptée en vol jusqu'à la fin de lecture du
|
||||
// flux (le ticket voyage avec le corps). Rien pour une API externe.
|
||||
// Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le
|
||||
// Go l'annulerait aussi, un aller-retour plus tard).
|
||||
let gate = if ep.external {
|
||||
None
|
||||
} else {
|
||||
if !keep_prewarm {
|
||||
self.shared.prewarm.stop();
|
||||
}
|
||||
self.gate_begin(keep_prewarm).await
|
||||
};
|
||||
match self.shared.http.post(&ep.url, &ep.key, body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200
|
||||
&& let Some(g) = &gate
|
||||
{
|
||||
// Le slot porte désormais cette requête (engineServed,
|
||||
// engineMarkMain pour une étape de la discussion).
|
||||
let _ = self
|
||||
.shared
|
||||
.store
|
||||
.internal_json(
|
||||
hyper::Method::POST,
|
||||
"/internal/v1/oai/served",
|
||||
Some(&json!({ "ticket": g.ticket })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let body = resp.bytes_stream().map(move |r| {
|
||||
let _keep = &gate;
|
||||
r.map_err(|e| e.to_string())
|
||||
});
|
||||
Ok(Reply {
|
||||
status,
|
||||
body: Box::pin(body),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = classify(&e);
|
||||
Err(SendError {
|
||||
kind,
|
||||
message: friendly_error(kind, &e.to_string(), self.port, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
name: &str,
|
||||
args: &Map<String, Value>,
|
||||
label: &str,
|
||||
) -> ToolOutcome {
|
||||
// Outils MCP, quand la façade tient le pool : ce que ferait le Go
|
||||
// (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux
|
||||
// allers-retours. Le Go n'en tire rien d'autre que le texte.
|
||||
@@ -907,6 +862,77 @@ impl TurnHost for GoTurnHost {
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnHost for GoTurnHost {
|
||||
async fn send(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
// Moteur local : requête comptée en vol jusqu'à la fin de lecture du
|
||||
// flux (le ticket voyage avec le corps). Rien pour une API externe.
|
||||
// Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le
|
||||
// Go l'annulerait aussi, un aller-retour plus tard).
|
||||
let gate = if ep.external {
|
||||
None
|
||||
} else {
|
||||
if !keep_prewarm {
|
||||
self.shared.prewarm.stop();
|
||||
}
|
||||
self.gate_begin(keep_prewarm).await
|
||||
};
|
||||
match self.shared.http.post(&ep.url, &ep.key, body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200
|
||||
&& let Some(g) = &gate
|
||||
{
|
||||
// Le slot porte désormais cette requête (engineServed,
|
||||
// engineMarkMain pour une étape de la discussion).
|
||||
let _ = self
|
||||
.shared
|
||||
.store
|
||||
.internal_json(
|
||||
hyper::Method::POST,
|
||||
"/internal/v1/oai/served",
|
||||
Some(&json!({ "ticket": g.ticket })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let body = resp.bytes_stream().map(move |r| {
|
||||
let _keep = &gate;
|
||||
r.map_err(|e| e.to_string())
|
||||
});
|
||||
Ok(Reply {
|
||||
status,
|
||||
body: Box::pin(body),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = classify(&e);
|
||||
Err(SendError {
|
||||
kind,
|
||||
message: friendly_error(kind, &e.to_string(), self.port, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
// Sous-agent du mode Code (lot 47) : un tour imbriqué, sur un
|
||||
// contexte isolé, dont seul le rapport revient.
|
||||
if name == "subagent"
|
||||
&& let Some(sub) = &self.sub
|
||||
{
|
||||
return ToolOutcome {
|
||||
result: super::subagent::run(self, sub, args).await,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.exec_tool_base(name, args, label).await
|
||||
}
|
||||
|
||||
fn save_tool_result(&self, result: &str) -> String {
|
||||
let id = tool_res_id(&self.conv_id);
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod persist;
|
||||
pub mod prewarm;
|
||||
pub mod sessions;
|
||||
pub mod stream;
|
||||
pub mod subagent;
|
||||
pub mod tplprobe;
|
||||
pub mod turn;
|
||||
pub mod types;
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::store::{BK_STATE, Store};
|
||||
/// Nature d'une complétion.
|
||||
pub const KIND_MAIN: &str = "main";
|
||||
pub const KIND_VERIFY: &str = "verify";
|
||||
/// Sous-agent du mode Code (`perfSubagent`).
|
||||
pub const KIND_SUBAGENT: &str = "subagent";
|
||||
pub const KIND_COMPACT: &str = "compact";
|
||||
pub const KIND_PREWARM: &str = "prewarm";
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! L'outil `subagent` du mode Code (lot 47) — port de `toolSubagent`
|
||||
//! (`code_subagent.go`).
|
||||
//!
|
||||
//! Le builder délègue une question bornée à un rôle en lecture seule
|
||||
//! (explorer, code-reviewer, planner) qui travaille dans SON PROPRE contexte :
|
||||
//! le prompt du rôle, le briefing machine et la tâche, rien de l'historique.
|
||||
//! Sa trace n'est ni rangée ni affichée : seul son texte final revient, comme
|
||||
//! résultat d'outil. Le contexte du rôle (prompt système, outils, capacités)
|
||||
//! vient du Go (`/internal/v1/turn/subagent`) ; le tour imbriqué tourne ici,
|
||||
//! comme la passe de vérification : un travail annexe (`engineSideJob`).
|
||||
//!
|
||||
//! Le tour du rôle est porté par [`SubHost`], qui délègue tout à son
|
||||
//! [`GoTurnHost`] sauf un nouvel appel à `subagent` : un sous-agent ne
|
||||
//! délègue pas à son tour (et le type du tour imbriqué ne se contient pas
|
||||
//! lui-même).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::compact::CompactConfig;
|
||||
use super::host::{GoTurnHost, TurnCompact};
|
||||
use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
|
||||
use super::turn::{
|
||||
Cancel, CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnConfig, TurnHost,
|
||||
run_turn,
|
||||
};
|
||||
use super::types::{Message, StreamEvent, Tool};
|
||||
use super::wire::{ChatEndpoint, EchoPolicy, TurnReasoning};
|
||||
|
||||
/// `subagentRoles` : les rôles délégables (`verifier` n'en est pas : seule
|
||||
/// la boucle du contrat le lance).
|
||||
pub const ROLES: [&str; 3] = ["explorer", "code-reviewer", "planner"];
|
||||
/// `subagentTemperature` (le TEMP du preset, s'il est posé, l'emporte).
|
||||
pub const TEMPERATURE: f64 = 0.6;
|
||||
/// `subagentMaxOutput` : un rapport, pas un vidage de fichiers.
|
||||
pub const MAX_OUTPUT: usize = 6000;
|
||||
|
||||
/// Ce que le tour du builder prête à ses sous-agents : l'endpoint, le
|
||||
/// raisonnement, l'écho, les motifs de relance et l'arrêt du tour.
|
||||
#[derive(Clone)]
|
||||
pub struct SubTurn {
|
||||
pub ep: ChatEndpoint,
|
||||
pub reasoning: TurnReasoning,
|
||||
pub echo: EchoPolicy,
|
||||
pub patterns: Arc<RetryPatterns>,
|
||||
pub cancel: Cancel,
|
||||
}
|
||||
|
||||
/// `subagentFraming` : le cadrage ajouté à la tâche — l'interlocuteur du rôle
|
||||
/// est le builder, pas un humain.
|
||||
pub fn framing(role: &str) -> &'static str {
|
||||
if role == "planner" {
|
||||
"\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
} else {
|
||||
"\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
}
|
||||
}
|
||||
|
||||
/// Les vérifications de `toolSubagent` avant tout appel au moteur : rôle,
|
||||
/// tâche, puis le garde-fou anti-récursion (`parent_role` : le rôle du tour
|
||||
/// qui délègue, vide ou `builder` pour le fil principal).
|
||||
pub fn precheck(args: &Map<String, Value>, parent_role: &str) -> Result<(String, String), String> {
|
||||
let s = |k: &str| {
|
||||
args.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let (role, task) = (s("role"), s("task"));
|
||||
if !ROLES.contains(&role.as_str()) {
|
||||
return Err(format!(
|
||||
"[erreur] rôle inconnu : {role} (attendu : {})",
|
||||
ROLES.join(", ")
|
||||
));
|
||||
}
|
||||
if task.is_empty() {
|
||||
return Err("[erreur] tâche vide — décris ce que le rôle doit chercher ou relire".into());
|
||||
}
|
||||
if !parent_role.is_empty() && parent_role != "builder" {
|
||||
return Err(
|
||||
"[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok((role, task))
|
||||
}
|
||||
|
||||
/// Le rapport rendu au builder : texte écrit après le dernier outil, borné.
|
||||
pub fn report(role: &str, content: &str, err: Option<&str>) -> String {
|
||||
if let Some(e) = err {
|
||||
return format!("[erreur] sous-agent {role} : {e}");
|
||||
}
|
||||
let res = content.trim();
|
||||
if res.is_empty() {
|
||||
return format!("[erreur] le sous-agent {role} n'a rien répondu");
|
||||
}
|
||||
let res = if res.chars().count() > MAX_OUTPUT {
|
||||
format!(
|
||||
"{}\n…[tronqué]",
|
||||
res.chars().take(MAX_OUTPUT).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
res.to_string()
|
||||
};
|
||||
format!("[{role}]\n{res}")
|
||||
}
|
||||
|
||||
fn caps_role(caps: &Value) -> &str {
|
||||
caps.get("Role").and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `toolSubagent` : délègue au rôle et rend son rapport.
|
||||
pub async fn run(parent: &GoTurnHost, sub: &SubTurn, args: &Map<String, Value>) -> String {
|
||||
let (role, task) = match precheck(args, caps_role(&parent.caps)) {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let store = &parent.shared.store;
|
||||
let view = match store
|
||||
.internal_post("/internal/v1/turn/subagent", &json!({ "role": role }))
|
||||
.await
|
||||
{
|
||||
Ok((s, v)) if s.as_u16() == 200 => v,
|
||||
Ok((_, v)) => {
|
||||
let e = v["error"].as_str().unwrap_or("vue du sous-agent refusée");
|
||||
return report(&role, "", Some(e));
|
||||
}
|
||||
Err(e) => return report(&role, "", Some(&e)),
|
||||
};
|
||||
let tools: Vec<Tool> = serde_json::from_value(view["tools"].clone()).unwrap_or_default();
|
||||
let caps = view["caps"].clone();
|
||||
let msgs = vec![
|
||||
Message {
|
||||
role: "system".into(),
|
||||
content: Some(view["system"].clone()),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(format!("{task}{}", framing(&role)))),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let compact: CompactConfig = parent.compact.cfg.clone();
|
||||
let tc = TurnConfig {
|
||||
cfg: parent.cfg.clone(),
|
||||
ep: sub.ep.clone(),
|
||||
tools: tools.clone(),
|
||||
temperature: TEMPERATURE,
|
||||
code: caps.get("Code").and_then(Value::as_bool) == Some(true),
|
||||
reasoning: sub.reasoning.clone(),
|
||||
echo: sub.echo.clone(),
|
||||
compact: compact.clone(),
|
||||
patterns: sub.patterns.clone(),
|
||||
};
|
||||
let host = SubHost(GoTurnHost {
|
||||
shared: parent.shared.clone(),
|
||||
conv_id: parent.conv_id.clone(),
|
||||
caps,
|
||||
cfg: parent.cfg.clone(),
|
||||
port: parent.port,
|
||||
summarizer: parent.summarizer.clone(),
|
||||
compact: TurnCompact {
|
||||
cfg: compact,
|
||||
caps: parent.compact.caps,
|
||||
criteria: String::new(),
|
||||
main_conv: parent.compact.main_conv.clone(),
|
||||
tools,
|
||||
effort: sub.reasoning.effort.clone(),
|
||||
kwargs: sub.reasoning.kwargs.clone().unwrap_or_default(),
|
||||
},
|
||||
queued: Box::new(Vec::new),
|
||||
// Les critères posés par le planner s'affichent comme ceux du fil.
|
||||
deltas: parent.deltas.clone(),
|
||||
perf_kind: super::perf::KIND_SUBAGENT,
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: None,
|
||||
});
|
||||
// Le rôle occupe le slot de la discussion du builder : effacé à la fin
|
||||
// pour que le builder reparte du cache RAM (pas entre ses lectures).
|
||||
let side = super::verify::side_job_begin(store).await;
|
||||
let mut out = String::new();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
match ev {
|
||||
// Seul le texte écrit APRÈS le dernier outil est le rapport.
|
||||
StreamEvent::ToolUsed(_) => out.clear(),
|
||||
StreamEvent::Content(c) => out.push_str(&c),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
};
|
||||
let r = run_turn(&host, &tc, msgs, &sub.cancel, &mut cb).await;
|
||||
super::verify::side_job_end(store, side).await;
|
||||
report(&role, &out, r.err.as_deref())
|
||||
}
|
||||
|
||||
/// Le [`TurnHost`] du tour d'un rôle délégué : celui de la façade, sans
|
||||
/// `subagent`.
|
||||
pub struct SubHost(pub GoTurnHost);
|
||||
|
||||
impl TurnHost for SubHost {
|
||||
async fn send(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
self.0.send(ep, body, keep_prewarm).await
|
||||
}
|
||||
|
||||
async fn project_refresh(&self, msgs: Vec<Message>) -> Vec<Message> {
|
||||
self.0.project_refresh(msgs).await
|
||||
}
|
||||
|
||||
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||
self.0.prewarm_keeps(prefix, payload)
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
if name == "subagent" {
|
||||
let result = match precheck(args, caps_role(&self.0.caps)) {
|
||||
Err(e) => e,
|
||||
// Un rôle sans `Role` : ne peut pas arriver (le Go le pose).
|
||||
Ok(_) => "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as".into(),
|
||||
};
|
||||
return ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.0.exec_tool_base(name, args, label).await
|
||||
}
|
||||
|
||||
fn save_tool_result(&self, result: &str) -> String {
|
||||
self.0.save_tool_result(result)
|
||||
}
|
||||
|
||||
async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option<Vec<Message>> {
|
||||
self.0.compact(phase, msgs).await
|
||||
}
|
||||
|
||||
fn compact_refused(&self, used: i64, msgs: &[Message]) -> bool {
|
||||
self.0.compact_refused(used, msgs)
|
||||
}
|
||||
|
||||
fn drain_queued(&self) -> Vec<Message> {
|
||||
self.0.drain_queued()
|
||||
}
|
||||
|
||||
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
|
||||
self.0.completed(tools, kwargs, effort)
|
||||
}
|
||||
|
||||
fn prefix_unstable(
|
||||
&self,
|
||||
tools: &[Tool],
|
||||
kwargs: Option<&Map<String, Value>>,
|
||||
effort: &str,
|
||||
) -> bool {
|
||||
self.0.prefix_unstable(tools, kwargs, effort)
|
||||
}
|
||||
|
||||
async fn prepare_images(&self, msgs: &[Message]) {
|
||||
self.0.prepare_images(msgs).await
|
||||
}
|
||||
|
||||
fn image_data_url(&self, r: &str) -> Option<String> {
|
||||
self.0.image_data_url(r)
|
||||
}
|
||||
|
||||
fn effort_remember(&self, want: &str, got: &str) {
|
||||
self.0.effort_remember(want, got)
|
||||
}
|
||||
|
||||
fn echo_refuse(&self, model: &str) {
|
||||
self.0.echo_refuse(model)
|
||||
}
|
||||
|
||||
fn cache_hint(&self) -> Option<CacheHintFn> {
|
||||
self.0.cache_hint()
|
||||
}
|
||||
|
||||
fn preflight(&self) -> Option<PreflightFn> {
|
||||
self.0.preflight()
|
||||
}
|
||||
|
||||
async fn keep_images(&self) -> Option<i64> {
|
||||
self.0.keep_images().await
|
||||
}
|
||||
|
||||
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
|
||||
self.0.keep_image(m).await
|
||||
}
|
||||
|
||||
async fn engine_slot(&self, ep: &ChatEndpoint) -> Option<u8> {
|
||||
self.0.engine_slot(ep).await
|
||||
}
|
||||
|
||||
async fn perf_record(&self, rec: PerfRec) -> PerfMarks {
|
||||
self.0.perf_record(rec).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Tests unitaires du sous-agent (lot 47) ; la parité avec le Go est dans
|
||||
//! `tests/lot6.rs` (`subagent_comme_le_go`).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(v: Value) -> Map<String, Value> {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refus_avant_le_moteur() {
|
||||
let e = |v, p| precheck(&args(v), p).unwrap_err();
|
||||
assert!(
|
||||
e(json!({"role": "verifier", "task": "x"}), "")
|
||||
.starts_with("[erreur] rôle inconnu : verifier")
|
||||
);
|
||||
assert!(e(json!({"role": 3, "task": "x"}), "").starts_with("[erreur] rôle inconnu : ("));
|
||||
assert!(e(json!({"role": "planner", "task": " \n"}), "").contains("tâche vide"));
|
||||
// Le rôle et la tâche d'abord, la récursion ensuite.
|
||||
assert!(e(json!({"role": "x", "task": "y"}), "explorer").contains("rôle inconnu"));
|
||||
assert!(
|
||||
e(json!({"role": "explorer", "task": "y"}), "explorer")
|
||||
.contains("ne peut pas en appeler un autre")
|
||||
);
|
||||
assert_eq!(
|
||||
precheck(
|
||||
&args(json!({"role": " planner ", "task": " découpe "})),
|
||||
"builder"
|
||||
),
|
||||
Ok(("planner".into(), "découpe".into()))
|
||||
);
|
||||
assert!(precheck(&args(json!({"role": "explorer", "task": "t"})), "").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapports() {
|
||||
assert_eq!(report("explorer", " vu \n", None), "[explorer]\nvu");
|
||||
assert_eq!(
|
||||
report("planner", " ", None),
|
||||
"[erreur] le sous-agent planner n'a rien répondu"
|
||||
);
|
||||
assert_eq!(
|
||||
report("explorer", "x", Some("panne")),
|
||||
"[erreur] sous-agent explorer : panne"
|
||||
);
|
||||
let long = "é".repeat(MAX_OUTPUT + 1);
|
||||
let r = report("explorer", &long, None);
|
||||
assert!(r.ends_with("\n…[tronqué]"));
|
||||
let kept = r
|
||||
.trim_start_matches("[explorer]\n")
|
||||
.trim_end_matches("\n…[tronqué]");
|
||||
assert_eq!(kept.chars().count(), MAX_OUTPUT);
|
||||
let pile = "é".repeat(MAX_OUTPUT);
|
||||
assert_eq!(
|
||||
report("explorer", &pile, None),
|
||||
format!("[explorer]\n{pile}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadrages() {
|
||||
assert!(framing("planner").contains("criteria tool"));
|
||||
assert!(framing("explorer").contains("file:line"));
|
||||
assert_eq!(framing("code-reviewer"), framing("explorer"));
|
||||
}
|
||||
@@ -58,7 +58,7 @@ pub struct Criterion {
|
||||
|
||||
/// `engineSideJob` : début d'un travail annexe auprès du Go ; `None` s'il ne
|
||||
/// répond pas.
|
||||
async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
pub(super) async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
store
|
||||
.internal_post("/internal/v1/engine/sidejob", &json!({}))
|
||||
.await
|
||||
@@ -68,7 +68,7 @@ async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
|
||||
/// Fin du travail annexe. Les requêtes servies ont déjà été comptées une à
|
||||
/// une (`/internal/v1/oai/served`) : rien à ajouter.
|
||||
async fn side_job_end(store: &Store, id: Option<i64>) {
|
||||
pub(super) async fn side_job_end(store: &Store, id: Option<i64>) {
|
||||
if let Some(id) = id {
|
||||
let _ = store
|
||||
.internal_post(
|
||||
@@ -478,7 +478,7 @@ impl ChatService {
|
||||
queued: Box::new(Vec::new),
|
||||
deltas: {
|
||||
let conv = conv.clone();
|
||||
Box::new(move |d| {
|
||||
Arc::new(move |d| {
|
||||
conv.append_delta(conv.epoch(), d);
|
||||
})
|
||||
},
|
||||
@@ -491,6 +491,15 @@ impl ChatService {
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
// Le builder relancé délègue comme dans son tour ; la
|
||||
// vérification, elle, ne délègue pas (garde-fou du rôle).
|
||||
sub: Some(super::subagent::SubTurn {
|
||||
ep: env.ep.clone(),
|
||||
reasoning: pe.reasoning.clone(),
|
||||
echo: env.echo.clone(),
|
||||
patterns: self.shared.patterns.clone(),
|
||||
cancel: run.cancel.clone(),
|
||||
}),
|
||||
};
|
||||
let mut out = PassOut::default();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct FakeGo {
|
||||
/// reçues (« block <seq> », « blocks »).
|
||||
pub recall_blocks: Mutex<Vec<serde_json::Value>>,
|
||||
pub recall_log: Mutex<Vec<String>>,
|
||||
/// Vues des rôles délégués (/internal/v1/turn/subagent, lot 47), par rôle.
|
||||
pub subagent_views: Mutex<HashMap<String, serde_json::Value>>,
|
||||
/// Discussion active annoncée et discussions supprimées.
|
||||
pub conv_log: Mutex<Vec<String>>,
|
||||
/// Tours : débuts, vues et fins demandés.
|
||||
@@ -474,6 +476,19 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
}
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/turn/subagent" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let role = v["role"].as_str().unwrap_or_default().to_string();
|
||||
st.turn_log.lock().unwrap().push(format!("subagent {role}"));
|
||||
match st.subagent_views.lock().unwrap().get(&role) {
|
||||
Some(view) => resp(200, view.to_string()),
|
||||
None => resp(
|
||||
400,
|
||||
serde_json::json!({ "error": format!("rôle inconnu : {role}") }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
"/internal/v1/turn/verifier" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
|
||||
+104
-2
@@ -702,11 +702,12 @@ async fn tour_complet_par_le_go() {
|
||||
kwargs: Default::default(),
|
||||
},
|
||||
queued: Box::new(Vec::new),
|
||||
deltas: Box::new(|_| {}),
|
||||
deltas: Arc::new(|_| {}),
|
||||
perf_kind: "main",
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: None,
|
||||
};
|
||||
let bash = Tool {
|
||||
kind: "function".into(),
|
||||
@@ -1102,11 +1103,12 @@ async fn hote_et_sonde_de_gabarit() {
|
||||
kwargs: Default::default(),
|
||||
},
|
||||
queued: Box::new(Vec::new),
|
||||
deltas: Box::new(|_| {}),
|
||||
deltas: Arc::new(|_| {}),
|
||||
perf_kind: "main",
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: None,
|
||||
}
|
||||
};
|
||||
let bash = Tool {
|
||||
@@ -2843,6 +2845,106 @@ async fn job_arriere_plan_par_la_facade() {
|
||||
assert_eq!(jobs.running(), 0);
|
||||
}
|
||||
|
||||
/// Lot 47 : l'outil `subagent` exécuté par la façade, rejoué sur le journal
|
||||
/// du Go (`internal/loki/parity_subagent_test.go` →
|
||||
/// `tests/parity_subagent/subagent.json`) : refus avant tout appel au moteur,
|
||||
/// puis un tour de la façade qui délègue — le rôle reçoit la vue du Go et sa
|
||||
/// tâche cadrée, le moteur ses réponses scriptées, et le builder le même
|
||||
/// rapport que le Go lui aurait rendu.
|
||||
#[tokio::test]
|
||||
async fn subagent_comme_le_go() {
|
||||
let fx: serde_json::Value =
|
||||
serde_json::from_str(include_str!("parity_subagent/subagent.json")).expect("journal");
|
||||
let views = fx["views"].as_object().unwrap();
|
||||
let sub_sse = |r: &serde_json::Value| -> String {
|
||||
if let Some(code) = r["status"].as_u64() {
|
||||
return format!("STATUS {code}\n{}", r["content"].as_str().unwrap_or(""));
|
||||
}
|
||||
let content = r["content"].as_str().unwrap_or("");
|
||||
if let Some(tool) = r["tool"].as_str() {
|
||||
let call = serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"s1","type":"function","function":{"name":tool,"arguments":r["args"].as_str().unwrap_or("")}}]}}]});
|
||||
let mut chunks = Vec::new();
|
||||
if !content.is_empty() {
|
||||
chunks.push(serde_json::json!({"choices":[{"delta":{"content":content}}]}));
|
||||
}
|
||||
chunks.push(call);
|
||||
chunks.push(serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}));
|
||||
return sse(&chunks);
|
||||
}
|
||||
answer(content)
|
||||
};
|
||||
for st in fx["steps"].as_array().unwrap() {
|
||||
let name = st["name"].as_str().unwrap();
|
||||
let args: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_value(st["args"].clone()).unwrap();
|
||||
let want = st["result"].as_str().unwrap();
|
||||
let reqs = st["requests"].as_array().unwrap();
|
||||
if reqs.is_empty() {
|
||||
let got =
|
||||
loki_rs::chat::subagent::precheck(&args, st["parent_role"].as_str().unwrap_or(""));
|
||||
assert_eq!(got.err().as_deref(), Some(want), "{name}");
|
||||
continue;
|
||||
}
|
||||
let call = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"d1","type":"function","function":{"name":"subagent","arguments":serde_json::Value::Object(args.clone()).to_string()}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let mut replies = vec![call];
|
||||
let scripted = st["replies"].as_array().cloned().unwrap_or_default();
|
||||
for i in 0..reqs.len() {
|
||||
replies.push(scripted.get(i).map(sub_sse).unwrap_or_else(|| answer("")));
|
||||
}
|
||||
replies.push(answer("fini"));
|
||||
let (url, seen) = fake_engine(replies).await;
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
std::fs::write(ws.path().join("cache.go"), "package x\n// cache LRU\n").unwrap();
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await;
|
||||
*go.tool_ctx.lock().unwrap() = Some(serde_json::json!({
|
||||
"cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": ""
|
||||
}));
|
||||
for (role, v) in views {
|
||||
go.subagent_views
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(role.clone(), v.clone());
|
||||
}
|
||||
svc.send(serde_json::json!({"message": "délègue", "cid": "s"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), reqs.len() + 2, "{name} : requêtes au moteur");
|
||||
let role = args["role"].as_str().unwrap().trim();
|
||||
for (i, want_req) in reqs.iter().enumerate() {
|
||||
let b = &seen[1 + i].1;
|
||||
let msgs = b["messages"].as_array().unwrap();
|
||||
let tools: Vec<&str> = b["tools"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|t| t["function"]["name"].as_str())
|
||||
.collect();
|
||||
let got = serde_json::json!({
|
||||
"system_is_view": msgs[0]["content"] == views[role]["system"],
|
||||
"user": msgs[1]["content"],
|
||||
"temperature": b["temperature"],
|
||||
"tools": tools,
|
||||
"messages": msgs.len(),
|
||||
});
|
||||
assert_eq!(&got, want_req, "{name} : requête {i} du rôle");
|
||||
}
|
||||
let last = seen.last().unwrap().1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let tool = last.iter().rfind(|m| m["role"] == "tool").unwrap();
|
||||
assert_eq!(tool["content"], want, "{name} : rapport rendu au builder");
|
||||
// Travail annexe : ouvert et fermé autour du tour du rôle.
|
||||
let side = go.side_jobs.lock().unwrap().clone();
|
||||
assert_eq!(side.len(), 2, "{name} : {side:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lot 46 : `recall` et `recall_search` sont exécutés par la façade sur
|
||||
/// l'archive du Go (rien par /tool/exec).
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"name": "rôle inconnu",
|
||||
"args": {
|
||||
"role": "architecte",
|
||||
"task": "x"
|
||||
},
|
||||
"result": "[erreur] rôle inconnu : architecte (attendu : explorer, code-reviewer, planner)",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "verifier refusé",
|
||||
"args": {
|
||||
"role": "verifier",
|
||||
"task": "x"
|
||||
},
|
||||
"result": "[erreur] rôle inconnu : verifier (attendu : explorer, code-reviewer, planner)",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "sans rôle",
|
||||
"args": {
|
||||
"task": "x"
|
||||
},
|
||||
"result": "[erreur] rôle inconnu : (attendu : explorer, code-reviewer, planner)",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "tâche vide",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": " "
|
||||
},
|
||||
"result": "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "tâche absente",
|
||||
"args": {
|
||||
"role": "planner"
|
||||
},
|
||||
"result": "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "pas de récursion",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": "x"
|
||||
},
|
||||
"parent_role": "explorer",
|
||||
"result": "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "vérificateur qui délègue",
|
||||
"args": {
|
||||
"role": "planner",
|
||||
"task": "x"
|
||||
},
|
||||
"parent_role": "verifier",
|
||||
"result": "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as",
|
||||
"requests": []
|
||||
},
|
||||
{
|
||||
"name": "réponse directe",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": " Où est le cache ? "
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"content": " Dans cache.go:2. \n"
|
||||
}
|
||||
],
|
||||
"result": "[explorer]\nDans cache.go:2.",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Où est le cache ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rapport après le dernier outil",
|
||||
"args": {
|
||||
"role": " code-reviewer ",
|
||||
"task": "Relis cache.go"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"tool": "grep",
|
||||
"args": "{\"pattern\":\"cache\"}",
|
||||
"content": "Je cherche d'abord."
|
||||
},
|
||||
{
|
||||
"tool": "read",
|
||||
"args": "{\"path\":\"cache.go\"}"
|
||||
},
|
||||
{
|
||||
"content": "RAS : cache.go:2 documente le LRU."
|
||||
}
|
||||
],
|
||||
"result": "[code-reviewer]\nRAS : cache.go:2 documente le LRU.",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 4
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rien répondu",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": "Vide ?"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"content": " "
|
||||
}
|
||||
],
|
||||
"result": "[explorer]\n_(le modèle n'a pas produit de réponse — finish: stop)_",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 3
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rien après l'outil",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": "Encore ?"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"tool": "grep",
|
||||
"args": "{\"pattern\":\"x\"}",
|
||||
"content": "narration"
|
||||
},
|
||||
{}
|
||||
],
|
||||
"result": "[explorer]\n_(le modèle n'a pas produit de réponse — finish: stop)_",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 4
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 5
|
||||
},
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rapport tronqué",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": "Tout ?"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"content": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééLine truncated
|
||||
}
|
||||
],
|
||||
"result": "[explorer]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééLine truncated
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Tout ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cadrage du planner",
|
||||
"args": {
|
||||
"role": "planner",
|
||||
"task": "Découpe le chantier"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"content": "1. lire\n2. écrire"
|
||||
}
|
||||
],
|
||||
"result": "[planner]\n1. lire\n2. écrire",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Découpe le chantier\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"criteria"
|
||||
],
|
||||
"messages": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moteur qui refuse",
|
||||
"args": {
|
||||
"role": "explorer",
|
||||
"task": "Panne ?"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"content": "{\"error\":{\"message\":\"requête refusée\"}}",
|
||||
"status": 400
|
||||
}
|
||||
],
|
||||
"result": "[erreur] sous-agent explorer : llama-server a renvoyé 400 : {\"error\":{\"message\":\"requête refusée\"}}",
|
||||
"requests": [
|
||||
{
|
||||
"system_is_view": true,
|
||||
"user": "Panne ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.",
|
||||
"temperature": 0.6,
|
||||
"tools": [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"bash"
|
||||
],
|
||||
"messages": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": {
|
||||
"code-reviewer": {
|
||||
"caps": {
|
||||
"Agent": true,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Code": true,
|
||||
"Role": "code-reviewer",
|
||||
"ComputerUse": false
|
||||
},
|
||||
"system": "You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.\n\nLook for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.\n\nFor each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"glob": {
|
||||
"description": "Only files matching this glob (e.g. *.go)",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File or directory to search (default .)",
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"description": "Go regexp",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "glob",
|
||||
"description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"description": "Glob, e.g. src/**/*.ts",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_status",
|
||||
"description": "git status of the working folder (branch + changed files, porcelain format).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_diff",
|
||||
"description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Limit the diff to this path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"explorer": {
|
||||
"caps": {
|
||||
"Agent": true,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Code": true,
|
||||
"Role": "explorer",
|
||||
"ComputerUse": false
|
||||
},
|
||||
"system": "You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.\n\nUse read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"glob": {
|
||||
"description": "Only files matching this glob (e.g. *.go)",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File or directory to search (default .)",
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"description": "Go regexp",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "glob",
|
||||
"description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"description": "Glob, e.g. src/**/*.ts",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_status",
|
||||
"description": "git status of the working folder (branch + changed files, porcelain format).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_diff",
|
||||
"description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Limit the diff to this path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"planner": {
|
||||
"caps": {
|
||||
"Agent": true,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Code": true,
|
||||
"Role": "planner",
|
||||
"ComputerUse": false
|
||||
},
|
||||
"system": "You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.\n\nProduce:\n1. A short restatement of the goal (one sentence).\n2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.\n3. The implementation steps, ordered, with the files each step touches.\n\nExplore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"glob": {
|
||||
"description": "Only files matching this glob (e.g. *.go)",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "File or directory to search (default .)",
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"description": "Go regexp",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "glob",
|
||||
"description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"description": "Glob, e.g. src/**/*.ts",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_status",
|
||||
"description": "git status of the working folder (branch + changed files, porcelain format).",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_diff",
|
||||
"description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"dir": {
|
||||
"description": "Repository subfolder (default: auto-detected)",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Limit the diff to this path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "criteria",
|
||||
"description": "Manage the acceptance criteria of the current task (the contract that defines DONE). action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": [
|
||||
"add",
|
||||
"set",
|
||||
"list",
|
||||
"clear"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "set: criterion id",
|
||||
"type": "integer"
|
||||
},
|
||||
"note": {
|
||||
"description": "set: why it failed / how it was verified",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"pending",
|
||||
"completed",
|
||||
"passed",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"texts": {
|
||||
"description": "add: one entry per criterion, short and testable",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user