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:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:30:00 +02:00
1 parent 9ccdc021cb
commit 7b754c0cb6
9 files changed
+272 -19

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ Method — in this order:
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.
- 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.
- 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.
+5 -1
View File
@@ -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
// déjà la compaction), approximative mais jamais absente.
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 {
switch {
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})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
asked = true
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.DropReasoning:
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
// code_verify.go. No-op hors mode code ou sans critères.
if !stale && ctx.Err() == nil {
c.codeVerifyLoop(ctx, caps, temperature, epoch)
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
+28 -6
View File
@@ -13,6 +13,7 @@ package loki
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
@@ -20,7 +21,7 @@ import (
type Criterion struct {
ID int `json:"id"`
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"`
}
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
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).
func critRender(list []Criterion) string {
var b strings.Builder
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
mark = "[✓]"
case "failed":
mark = "[✗]"
case "completed":
mark = "[~]" // fait selon le builder, pas encore vérifié
}
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
if c.Note != "" {
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
Function: ToolFunction{
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 passed|failed|pending, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
"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: map[string]any{
"type": "object",
"properties": map[string]any{
"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"},
"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"},
},
"required": []string{"action"},
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
critNotify(list)
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
case "set":
idf, _ := args["id"].(float64)
id := int(idf)
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
// é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)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
+54 -9
View File
@@ -25,10 +25,19 @@ import (
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
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
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
// 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
}
convID := convEnsureActive()
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
if caps.Role == "planner" {
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++ {
if ctx.Err() != nil {
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)_"})
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
// critères en échec. Sa trace est persistée comme un tour ordinaire.
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
// 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
for _, cr := range list {
if cr.Status != "passed" {
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"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
fixMsg := Message{Role: "user", Content: "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."}
fixMsg := Message{Role: "user", Content: instruction}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return
return false
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
@@ -123,6 +161,12 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
if 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)
return true
})
@@ -135,6 +179,7 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
}
c.mu.Unlock()
c.persist()
return asked
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
+110
View File
@@ -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)
}
}
+70
View File
@@ -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)
}
}
+2 -1
View File
@@ -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-failed{color:var(--err,#b3564d)}
#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}
@@ -9894,7 +9895,7 @@ function renderCriteria(list){
for(const c of list){
const li = document.createElement('li');
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 : '');
ul.appendChild(li);
}
+1 -1
View File
@@ -110,7 +110,7 @@ function renderCriteria(list){
for(const c of list){
const li = document.createElement('li');
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 : '');
ul.appendChild(li);
}
+1
View File
@@ -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-failed{color:var(--err,#b3564d)}
#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}