mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
Mode Code : vérifier quand le builder a fini, jamais pendant une question
Repris du buildAgentNudge d'OpenFox (2.0.133). La passe de vérification partait à chaque fin de tour de build, y compris quand Qwen s'arrêtait sur « ensuite je vais… » ou venait de poser une question avec ask. Chaque passe inutile coûte un prefill complet, évince le cache KV du fil, et produit des « failed » qui ne disent que « pas encore fait » — en courant par-dessus la question restée sans réponse. - Nouveau statut « completed » : le builder marque un critère fait une fois vérifié par lui-même. Seule la vérification marque passed/failed. - Fin de tour avec des critères encore ouverts : le builder est relancé sur ces critères (deux fois au plus) avant toute vérification. - Fin de tour sur une question (ask) : pas de vérification, ni de correction, avant la réponse de l'utilisateur ; idem si le builder pose une question pendant une correction. - Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien le bon critère au lieu de #0. - Le panneau des critères montre « completed » (◐). - Test de non-régression de la passe de vérification (code_verify_test.go). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ccdc021cb
commit
7b754c0cb6
9 files changed
+272
-19
No files matched your search
@@ -10,7 +10,7 @@ Method — in this order:
|
|||||||
Rules:
|
Rules:
|
||||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||||
- You may NOT mark a criterion passed — the verification pass does that.
|
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
|
||||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||||
@@ -785,6 +785,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||||
// déjà la compaction), approximative mais jamais absente.
|
// déjà la compaction), approximative mais jamais absente.
|
||||||
sawUsage := false
|
sawUsage := false
|
||||||
|
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
|
||||||
|
// pas de vérification du mode Code avant sa réponse.
|
||||||
|
asked := false
|
||||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||||
switch {
|
switch {
|
||||||
case ev.Err != nil:
|
case ev.Err != nil:
|
||||||
@@ -851,6 +854,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||||
case ev.Ask != nil:
|
case ev.Ask != nil:
|
||||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||||
|
asked = true
|
||||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||||
case ev.DropReasoning:
|
case ev.DropReasoning:
|
||||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||||
@@ -900,7 +904,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||||
// code_verify.go. No-op hors mode code ou sans critères.
|
// code_verify.go. No-op hors mode code ou sans critères.
|
||||||
if !stale && ctx.Err() == nil {
|
if !stale && ctx.Err() == nil {
|
||||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
msgs = append([]Message(nil), c.Messages...)
|
msgs = append([]Message(nil), c.Messages...)
|
||||||
ctxUsed = c.CtxUsed
|
ctxUsed = c.CtxUsed
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ package loki
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,7 @@ import (
|
|||||||
type Criterion struct {
|
type Criterion struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Status string `json:"status"` // pending | passed | failed
|
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// critOpen : les critères que le builder n'a pas encore déclarés faits —
|
||||||
|
// pending, ou failed et pas encore repris (la reprise les remet à completed).
|
||||||
|
func critOpen(list []Criterion) []Criterion {
|
||||||
|
var out []Criterion
|
||||||
|
for _, c := range list {
|
||||||
|
if c.Status == "pending" || c.Status == "failed" || c.Status == "" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||||
func critRender(list []Criterion) string {
|
func critRender(list []Criterion) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
|
|||||||
mark = "[✓]"
|
mark = "[✓]"
|
||||||
case "failed":
|
case "failed":
|
||||||
mark = "[✗]"
|
mark = "[✗]"
|
||||||
|
case "completed":
|
||||||
|
mark = "[~]" // fait selon le builder, pas encore vérifié
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||||
if c.Note != "" {
|
if c.Note != "" {
|
||||||
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
|
|||||||
Function: ToolFunction{
|
Function: ToolFunction{
|
||||||
Name: "criteria",
|
Name: "criteria",
|
||||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
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 passed|failed|pending, optional note), " +
|
"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; only the verification pass may mark passed.",
|
"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: map[string]any{
|
Parameters: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
"status": map[string]any{"type": "string", "enum": []string{"pending", "completed", "passed", "failed"}},
|
||||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||||
},
|
},
|
||||||
"required": []string{"action"},
|
"required": []string{"action"},
|
||||||
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
|
|||||||
critNotify(list)
|
critNotify(list)
|
||||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||||
case "set":
|
case "set":
|
||||||
idf, _ := args["id"].(float64)
|
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
|
||||||
id := int(idf)
|
// écrivent volontiers l'id entre guillemets, qui tombait sinon à #0.
|
||||||
|
id := 0
|
||||||
|
switch v := args["id"].(type) {
|
||||||
|
case float64:
|
||||||
|
id = int(v)
|
||||||
|
case string:
|
||||||
|
id, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(v), "#"))
|
||||||
|
}
|
||||||
status, _ := args["status"].(string)
|
status, _ := args["status"].(string)
|
||||||
note, _ := args["note"].(string)
|
note, _ := args["note"].(string)
|
||||||
if status == "passed" && !allowPass {
|
if status == "passed" && !allowPass {
|
||||||
|
|||||||
@@ -25,10 +25,19 @@ import (
|
|||||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||||
const codeMaxFixTurns = 2
|
const codeMaxFixTurns = 2
|
||||||
|
|
||||||
|
// codeMaxContinue : relances du builder qui a rendu la main avec des critères
|
||||||
|
// encore ouverts (« ensuite je vais… ») avant de vérifier quand même. Reprise
|
||||||
|
// du buildAgentNudge d'OpenFox : une passe de vérification sur un travail
|
||||||
|
// inachevé, c'est un prefill complet, le cache KV du fil évincé, et des
|
||||||
|
// « failed » qui ne disent que « pas encore fait ».
|
||||||
|
const codeMaxContinue = 2
|
||||||
|
|
||||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
|
||||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
|
||||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
// passe avant toute vérification.
|
||||||
|
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int, asked bool) {
|
||||||
|
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil || asked {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
convID := convEnsureActive()
|
convID := convEnsureActive()
|
||||||
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
|||||||
if caps.Role == "planner" {
|
if caps.Role == "planner" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Le builder vérifie lui-même puis marque « completed » ce qu'il estime
|
||||||
|
// fait. Tant qu'il reste des critères ouverts, on le relance plutôt que de
|
||||||
|
// vérifier un travail inachevé.
|
||||||
|
for n := 0; n < codeMaxContinue; n++ {
|
||||||
|
open := critOpen(list)
|
||||||
|
if len(open) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
msg := "Not done yet — these acceptance criteria are still open:\n" + critRender(open) +
|
||||||
|
"\n\nContinue working on them. Once one is done and checked, mark it completed (criteria action=set, status=completed). If you need the user's decision, use ask."
|
||||||
|
if c.runBuilderTurn(ctx, caps, temperature, epoch, msg) || ctx.Err() != nil {
|
||||||
|
return // question posée à l'utilisateur, ou arrêt
|
||||||
|
}
|
||||||
|
list = critList(convID)
|
||||||
|
if critAllPassed(list) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
for fix := 0; ; fix++ {
|
for fix := 0; ; fix++ {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
@@ -59,7 +86,9 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
|||||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
|
||||||
|
return // le builder attend une réponse de l'utilisateur
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,24 +120,33 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
|
||||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
// true si le builder a posé une question à l'utilisateur (ask).
|
||||||
|
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) bool {
|
||||||
var failed []Criterion
|
var failed []Criterion
|
||||||
for _, cr := range list {
|
for _, cr := range list {
|
||||||
if cr.Status != "passed" {
|
if cr.Status != "passed" {
|
||||||
failed = append(failed, cr)
|
failed = append(failed, cr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return c.runBuilderTurn(ctx, caps, temperature, epoch, "Verification failed on these criteria:\n"+critRender(failed)+
|
||||||
|
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed. Mark each fixed criterion completed again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBuilderTurn relance le BUILDER sur l'historique normal avec une consigne
|
||||||
|
// (correction après vérification, ou relance sur des critères ouverts). Sa
|
||||||
|
// trace est persistée comme un tour ordinaire. Renvoie true si le builder a
|
||||||
|
// posé une question à l'utilisateur (ask) : la boucle doit alors s'arrêter.
|
||||||
|
func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperature float64, epoch int, instruction string) (asked bool) {
|
||||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||||
|
|
||||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
fixMsg := Message{Role: "user", Content: instruction}
|
||||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.epoch != epoch {
|
if c.epoch != epoch {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
c.Messages = append(c.Messages, fixMsg)
|
c.Messages = append(c.Messages, fixMsg)
|
||||||
msgs := append([]Message(nil), c.Messages...)
|
msgs := append([]Message(nil), c.Messages...)
|
||||||
@@ -123,6 +161,12 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
|
|||||||
if ev.Content != "" {
|
if ev.Content != "" {
|
||||||
content.WriteString(ev.Content)
|
content.WriteString(ev.Content)
|
||||||
}
|
}
|
||||||
|
if ev.ToolUsed != nil {
|
||||||
|
content.Reset() // déjà dans le message tool_calls (voir generate)
|
||||||
|
}
|
||||||
|
if ev.Ask != nil {
|
||||||
|
asked = true
|
||||||
|
}
|
||||||
c.forwardStream(ev, epoch)
|
c.forwardStream(ev, epoch)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -135,6 +179,7 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.persist()
|
c.persist()
|
||||||
|
return asked
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moteurScripte : faux llama-server qui répond selon le DERNIER message reçu.
|
||||||
|
// Un message `tool` en queue → texte de clôture ; sinon la règle dont le motif
|
||||||
|
// figure dans le dernier message utilisateur → appel d'outil criteria.
|
||||||
|
func moteurScripte(t *testing.T, regles map[string]string) (port string, vus *[]string) {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var log []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
last := body.Messages[len(body.Messages)-1]
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
args := ""
|
||||||
|
if last.Role == "user" {
|
||||||
|
txt := msgText(last)
|
||||||
|
mu.Lock()
|
||||||
|
log = append(log, txt)
|
||||||
|
mu.Unlock()
|
||||||
|
for motif, a := range regles {
|
||||||
|
if strings.Contains(txt, motif) {
|
||||||
|
args = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args != "" {
|
||||||
|
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"criteria","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(sseChunk("ok")))
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
return u.Port(), &log
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le builder rend la main avec un critère encore ouvert : on le RELANCE (il le
|
||||||
|
// marque completed) avant de vérifier, au lieu de vérifier un travail
|
||||||
|
// inachevé (repris du buildAgentNudge d'OpenFox).
|
||||||
|
func TestVerificationAttendQueLeBuilderAitFini(t *testing.T) {
|
||||||
|
withWorkspace(t)
|
||||||
|
id := convEnsureActive()
|
||||||
|
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||||
|
port, vus := moteurScripte(t, map[string]string{
|
||||||
|
"Not done yet": `{"action":"set","id":"1","status":"completed"}`,
|
||||||
|
"Verify each non-pass": `{"action":"set","id":1,"status":"passed"}`,
|
||||||
|
})
|
||||||
|
if err := SetConfigKey("PORT", port); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := newTestConv()
|
||||||
|
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||||
|
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, false)
|
||||||
|
|
||||||
|
if len(*vus) < 2 || !strings.Contains((*vus)[0], "Not done yet") || !strings.Contains((*vus)[1], "Verify each non-pass") {
|
||||||
|
t.Fatalf("ordre des passes = %q, attendu relance du builder PUIS vérification", *vus)
|
||||||
|
}
|
||||||
|
if l := critList(id); len(l) != 1 || l[0].Status != "passed" {
|
||||||
|
t.Fatalf("critère final : %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tour terminé sur une question à l'utilisateur : aucune passe ne part avant
|
||||||
|
// sa réponse.
|
||||||
|
func TestPasDeVerificationApresUneQuestion(t *testing.T) {
|
||||||
|
withWorkspace(t)
|
||||||
|
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||||
|
port, vus := moteurScripte(t, nil)
|
||||||
|
if err := SetConfigKey("PORT", port); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := newTestConv()
|
||||||
|
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||||
|
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, true)
|
||||||
|
if len(*vus) != 0 {
|
||||||
|
t.Fatalf("%d requête(s) au moteur alors qu'une question attend l'utilisateur", len(*vus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien #2.
|
||||||
|
func TestCritereIdEnChaine(t *testing.T) {
|
||||||
|
withWorkspace(t)
|
||||||
|
id := convEnsureActive()
|
||||||
|
toolCriteria(map[string]any{"action": "add", "texts": []any{"a", "b"}}, false)
|
||||||
|
if out := toolCriteria(map[string]any{"action": "set", "id": "#2", "status": "completed"}, false); !strings.HasPrefix(out, "[ok]") {
|
||||||
|
t.Fatalf("id en chaîne refusé : %s", out)
|
||||||
|
}
|
||||||
|
if l := critList(id); l[1].Status != "completed" || l[0].Status != "pending" {
|
||||||
|
t.Fatalf("mauvais critère modifié : %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fauxMoteurOutil : un llama-server de comédie qui, à la PREMIÈRE requête,
|
||||||
|
// répond par un appel d'outil (name/args), puis par un texte à la suivante.
|
||||||
|
func fauxMoteurOutil(t *testing.T, name, args string) (port string, requêtes *int32) {
|
||||||
|
t.Helper()
|
||||||
|
var n int32
|
||||||
|
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
if atomic.AddInt32(&n, 1) == 1 {
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"` + name + `","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(sseChunk("Verdict enregistré.")))
|
||||||
|
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
return u.Port(), &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// La passe de vérification (Role=verifier) doit pouvoir marquer un critère
|
||||||
|
// passed via l'outil criteria — c'est tout son rôle. Vécu : « en mode code,
|
||||||
|
// le système de vérification ne coche plus les critères ».
|
||||||
|
func TestPasseDeVerificationCocheLesCriteres(t *testing.T) {
|
||||||
|
withWorkspace(t)
|
||||||
|
id := convEnsureActive()
|
||||||
|
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||||
|
|
||||||
|
port, n := fauxMoteurOutil(t, "criteria", `{"action":"set","id":1,"status":"passed"}`)
|
||||||
|
if err := SetConfigKey("PORT", port); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := Caps{Agent: true, Code: true, Role: "verifier"}
|
||||||
|
var tools []string
|
||||||
|
_, err := runChat(context.Background(), []Message{
|
||||||
|
{Role: "system", Content: rolePrompt("verifier")},
|
||||||
|
{Role: "user", Content: "Verify."},
|
||||||
|
}, 0.2, caps, func(ev StreamEvent) bool {
|
||||||
|
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||||
|
tools = append(tools, ev.ToolUsed.Name+" → "+ev.ToolUsed.Result)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if *n < 2 {
|
||||||
|
t.Fatalf("%d requête(s) au moteur : l'appel d'outil n'a pas été suivi d'un second tour", *n)
|
||||||
|
}
|
||||||
|
list := critList(id)
|
||||||
|
if len(list) != 1 || list[0].Status != "passed" {
|
||||||
|
t.Fatalf("critère non coché par le vérificateur : %+v — outils : %v", list, tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2143,6 +2143,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
#criteria-list li.crit-passed{color:var(--accent)}
|
#criteria-list li.crit-passed{color:var(--accent)}
|
||||||
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
||||||
#criteria-list li.crit-pending{color:var(--dim)}
|
#criteria-list li.crit-pending{color:var(--dim)}
|
||||||
|
#criteria-list li.crit-completed{color:var(--text)}
|
||||||
|
|
||||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||||
|
|
||||||
@@ -9894,7 +9895,7 @@ function renderCriteria(list){
|
|||||||
for(const c of list){
|
for(const c of list){
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'crit-'+(c.status||'pending');
|
li.className = 'crit-'+(c.status||'pending');
|
||||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
|
||||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function renderCriteria(list){
|
|||||||
for(const c of list){
|
for(const c of list){
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'crit-'+(c.status||'pending');
|
li.className = 'crit-'+(c.status||'pending');
|
||||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : (c.status==='completed' ? '◐' : '○'));
|
||||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2108,6 +2108,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
#criteria-list li.crit-passed{color:var(--accent)}
|
#criteria-list li.crit-passed{color:var(--accent)}
|
||||||
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
||||||
#criteria-list li.crit-pending{color:var(--dim)}
|
#criteria-list li.crit-pending{color:var(--dim)}
|
||||||
|
#criteria-list li.crit-completed{color:var(--text)}
|
||||||
|
|
||||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user