diff --git a/README.md b/README.md index 7645fb8..a81ac8d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/loki/code_subagent.go b/internal/loki/code_subagent.go new file mode 100644 index 0000000..7371659 --- /dev/null +++ b/internal/loki/code_subagent.go @@ -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 +} diff --git a/internal/loki/code_subagent_test.go b/internal/loki/code_subagent_test.go new file mode 100644 index 0000000..54898e4 --- /dev/null +++ b/internal/loki/code_subagent_test.go @@ -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") + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 575b2b9..04def32 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -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) {