mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Mode code : sous-agents (explorer, code-reviewer, planner)
Reprise de l'idée des sous-agents d'OpenFox, sur la mécanique déjà en place pour la passe de vérification : un runChat isolé, non persisté. L'outil `subagent` délègue une question bornée à un rôle qui travaille dans SON propre contexte et ne rend que sa réponse. Sur un modèle local, c'est la fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix lectures de fichiers qui restaient ensuite dans l'historique jusqu'à la compaction, alors que seule la réponse comptait. Les rôles explorer et code-reviewer, jusqu'ici définis mais jamais appelés, deviennent utilisables. Tous les rôles délégués sont en LECTURE SEULE : pas de write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les yeux de l'utilisateur), pas de subagent (aucune récursion), pas de mémoire ni de web. Seul le planner pose des critères — son prompt le lui demande — et marquer un critère « passé » reste le privilège de la passe de vérification. Le rôle verifier n'est PAS délégable : le builder se décernerait son propre satisfecit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
This commit is contained in:
4 files changed
+213
-1
No files matched your search
@@ -291,7 +291,15 @@ Ajoutées par ce fork :
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`.
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
|
||||
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
|
||||
// la réponse.
|
||||
//
|
||||
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
|
||||
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
|
||||
// vérification (code_verify.go : un runChat isolé, non persisté).
|
||||
//
|
||||
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
|
||||
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
|
||||
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
|
||||
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
|
||||
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
|
||||
//
|
||||
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
|
||||
// le code), code-reviewer (relit un changement), planner (découpe un travail).
|
||||
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
|
||||
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
|
||||
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
|
||||
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
|
||||
// pas un vidage de fichiers.
|
||||
const subagentMaxOutput = 6000
|
||||
|
||||
func subagentTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "subagent",
|
||||
// Description au plus court : elle part dans CHAQUE requête (budget du
|
||||
// préambule, cf. TestSystemPromptStaysLean).
|
||||
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"role": map[string]any{
|
||||
"type": "string",
|
||||
"enum": subagentRoles,
|
||||
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
|
||||
},
|
||||
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
|
||||
},
|
||||
"required": []string{"role", "task"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
|
||||
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
|
||||
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
|
||||
// persistée ni diffusée : seul son texte final revient.
|
||||
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
|
||||
role, _ := args["role"].(string)
|
||||
task, _ := args["task"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
task = strings.TrimSpace(task)
|
||||
if !isSubagentRole(role) {
|
||||
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
|
||||
}
|
||||
if task == "" {
|
||||
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
|
||||
}
|
||||
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
|
||||
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
|
||||
// facture en jetons devient exponentielle pour une question unique.
|
||||
if parent.Role != "" && parent.Role != "builder" {
|
||||
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."
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
||||
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
||||
if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return "[erreur] sous-agent " + role + " : " + err.Error()
|
||||
}
|
||||
res := strings.TrimSpace(out.String())
|
||||
if res == "" {
|
||||
return "[erreur] le sous-agent " + role + " n'a rien répondu"
|
||||
}
|
||||
if r := []rune(res); len(r) > subagentMaxOutput {
|
||||
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
|
||||
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
|
||||
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
|
||||
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
parent Caps
|
||||
want string
|
||||
}{
|
||||
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
|
||||
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := toolSubagent(context.Background(), tc.args, tc.parent)
|
||||
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
|
||||
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
|
||||
// planner pose des critères.
|
||||
func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
|
||||
for _, role := range subagentRoles {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
|
||||
names[tool.Function.Name] = true
|
||||
}
|
||||
for f := range forbidden {
|
||||
if names[f] {
|
||||
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
|
||||
}
|
||||
}
|
||||
if !names["read"] || !names["grep"] {
|
||||
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
|
||||
}
|
||||
if got := names["criteria"]; got != (role == "planner") {
|
||||
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,28 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
// Rôles DÉLÉGUÉS (outil subagent) : lecture seule, et rien d'autre. Ni
|
||||
// write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur), ni critères (seule la passe de vérification les
|
||||
// marque), ni subagent (pas de récursion), ni mémoire ni web — chaque schéma
|
||||
// coûte du contexte, et un explorateur n'a que du code à lire.
|
||||
if caps.Code && isSubagentRole(caps.Role) {
|
||||
tools := []Tool{readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool()}
|
||||
if caps.Role == "planner" {
|
||||
// Le planificateur POSE le contrat (criteria action=add) : c'est la
|
||||
// moitié de son travail, et son prompt le lui demande. Marquer un
|
||||
// critère « passé » lui reste interdit — allowPass ne vaut que pour
|
||||
// la passe de vérification (voir toolCriteria).
|
||||
tools = append(tools, criteriaTool())
|
||||
}
|
||||
if caps.Role == "explorer" || caps.Role == "code-reviewer" {
|
||||
// bash : lancer un test, compter des occurrences. Les commandes
|
||||
// catastrophiques restent refusées (code_policy.go) et les chemins
|
||||
// bornés au dossier de la discussion.
|
||||
tools = append(tools, bashTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
// Mémoire longue de la conversation (chat_recall.go) : le compactage
|
||||
@@ -346,6 +368,9 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
// Délégation à un rôle en contexte isolé (code_subagent.go). Réservée au
|
||||
// fil principal : un sous-agent ne délègue pas à son tour.
|
||||
tools = append(tools, subagentTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
@@ -1419,6 +1444,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = toolGitClone(ctx, args)
|
||||
case "criteria":
|
||||
result = toolCriteria(args, caps.Role == "verifier")
|
||||
case "subagent":
|
||||
result = toolSubagent(ctx, args, caps)
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
|
||||
Reference in new issue
Block a user