Files
MichaelandClaude Opus 5.5 7b754c0cb6 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>
2026-10-02 23:30:00 +02:00

228 lines
6.6 KiB
Go

package loki
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
// se termine « vraiment » que quand tout est passed — c'est le contrat.
//
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
// la discussion, sa suppression les emporte.
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
Note string `json:"note,omitempty"`
}
const critMaxCount = 20
func critKey(convID string) string { return "crit:" + convID }
func critList(convID string) []Criterion {
var list []Criterion
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
}
func critSave(convID string, list []Criterion) {
if len(list) == 0 {
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
return
}
b, err := json.Marshal(list)
if err != nil {
return
}
_ = putStoreBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
// remplie — il n'y a simplement pas de contrat.
func critAllPassed(list []Criterion) bool {
if len(list) == 0 {
return false
}
for _, c := range list {
if c.Status != "passed" {
return false
}
}
return true
}
func critPending(list []Criterion) int {
n := 0
for _, c := range list {
if c.Status != "passed" {
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).
func critRender(list []Criterion) string {
var b strings.Builder
for _, c := range list {
mark := "[ ]"
switch c.Status {
case "passed":
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 != "" {
fmt.Fprintf(&b, " — %s", c.Note)
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func criteriaTool() Tool {
return Tool{
Type: "function",
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, 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", "completed", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
},
},
}
}
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
// contraint plus rien).
func toolCriteria(args map[string]any, allowPass bool) string {
convID := convEnsureActive()
action, _ := args["action"].(string)
list := critList(convID)
switch action {
case "list", "":
if len(list) == 0 {
return "[aucun critère]"
}
return critRender(list)
case "add":
raw, _ := args["texts"].([]any)
next := 1
for _, c := range list {
if c.ID >= next {
next = c.ID + 1
}
}
added := 0
for _, r := range raw {
s, ok := r.(string)
if !ok || strings.TrimSpace(s) == "" {
continue
}
if len(list) >= critMaxCount {
break
}
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
next++
added++
}
if added == 0 {
return "[erreur] aucun critère fourni (texts)"
}
critSave(convID, list)
critNotify(list)
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
case "set":
// 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 {
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
}
for i := range list {
if list[i].ID != id {
continue
}
if status != "" {
list[i].Status = status
}
if note != "" {
list[i].Note = strings.TrimSpace(note)
}
critSave(convID, list)
critNotify(list)
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
}
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
case "clear":
critDrop(convID)
critNotify(nil)
return "[ok] critères effacés"
}
return "[erreur] action inconnue : " + action
}
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
// on publie sur l'epoch courant.
func critNotify(list []Criterion) {
if conv == nil {
return
}
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
if list == nil {
list = []Criterion{}
}
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
}