mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Jusqu'ici, impossible de savoir si une ligne volatile s'était glissée dans le prompt, si une mise à jour du moteur avait cassé les points de reprise ou si l'acceptation du MTP s'était effondrée : le chunk final de llama.cpp porte ces chiffres (cache_n, draft_n, cached_tokens), on les jetait. Lecture seule : aucun champ ajouté aux requêtes, et le comptage du contexte (usage.prompt_tokens → CtxUsed → compaction) ne change pas. - perf_log.go : décodage à part et tolérant (perfWire) — un compteur mal typé ne jette plus le chunk final ni ne fait échouer une compaction ; cached_tokens, strictement entier dans streamChunk, y déménage aussi. - Inconnu n'est pas 0 : pointeurs, et l'UI garde « prefill N tok » quand le moteur ne dit rien de son cache. - Nature (main, subagent, verify, task, compact, bench, foreign) portée par le contexte, pas par Caps ; TTFT au premier delta de tout type. - Perte de cache = total précédent − cache_n, seulement quand les messages prolongent strictement ceux de la complétion précédente de la même discussion et de la même nature (empreintes cumulées) ; jamais sur un flux coupé. Ce qui s'est intercalé (sous-agent, client /v1, bench) est nommé. - Anneau de 5000 entrées en mémoire, sans texte ; GET /api/perf/summary (derrière la clé) : taux de cache, recalcul par tour et par nature, médianes pp/tg par profondeur, acceptation du brouillon. - Ligne [perf] sur stderr seulement avec LOKI_PERF_LOG. - UI : « prefill X nouveaux / Y en cache », ambre au-delà d'un seuil relevé sur un modèle hybride (espacement des points de reprise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.9 KiB
Go
136 lines
5.9 KiB
Go
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"}
|
|
|
|
// subagentTemperature : voir toolSubagent.
|
|
const subagentTemperature = 0.6
|
|
|
|
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
|
|
// Le sous-agent occupe le slot de la conversation du builder : une fois son
|
|
// rapport rendu, on l'efface pour que le builder reparte du cache RAM
|
|
// (llm_slots.go). Pas entre ses lectures : seulement à la fin.
|
|
defer engineSideJob()()
|
|
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
|
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
|
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
|
|
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
|
|
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
|
|
// narration entre deux lectures n'est pas le rapport.
|
|
if _, err := runChat(withPerfKind(ctx, perfSubagent), msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
|
|
if ev.ToolUsed != nil {
|
|
out.Reset()
|
|
}
|
|
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
|
|
}
|