mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
Cache : la ligne MCP du préambule ne manque plus au premier tour après un démarrage
Le préambule système était bâti (InjectSkills → baseSystemPrompt) AVANT que runChat n'appelle EnabledTools, donc avant que mcpTools n'ouvre les connexions. mcpPromptLine lisait le pool encore vide : le premier tour après un démarrage partait sans la ligne MCP, le second avec — tout le prompt à recalculer pour une ligne. Elle comptait en plus les outils masqués par l'utilisateur, et s'affichait pour le planner, qui ne reçoit aucun outil MCP. - prepareTurn calcule les outils UNE fois par tour, puis le préambule à partir d'eux ; runChatTools les reprend tels quels (pas de second passage par mcpEnsureAll, qui retentait deux fois un serveur en panne). - mcpPromptLine se déduit de cette tranche : seuls les outils réellement envoyés sont comptés, et rien n'est annoncé sans outil MCP. - trackerList départage les égalités d'horodatage par nom puis slug : la liste part dans le contexte, un ordre tiré au sort changeait le prompt. - Le commentaire de tasks_run.go ne prétend plus que le préfixe d'une tâche égale celui du chat (dossier de travail et taskCaps diffèrent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9263df404d
commit
7dd319a058
12 files changed
+202
-34
No files matched your search
@@ -73,7 +73,8 @@ func cmdChat(args []string) error {
|
||||
msgs = compacted
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
|
||||
@@ -821,7 +821,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// 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 {
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
|
||||
@@ -50,8 +50,9 @@ const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
sp := baseSystemPrompt(caps)
|
||||
tb, err := json.Marshal(EnabledTools(caps))
|
||||
tools := EnabledTools(caps)
|
||||
sp := baseSystemPrompt(caps, tools)
|
||||
tb, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func TestSystemPromptStaysLean(t *testing.T) {
|
||||
// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
|
||||
// dans la MÊME requête et les décrivent déjà un par un.
|
||||
func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}, nil)
|
||||
for _, line := range strings.Split(sp, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(l, "- ") {
|
||||
@@ -88,7 +89,7 @@ func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
|
||||
// réponse.
|
||||
func TestSystemPromptEmptyWithoutTools(t *testing.T) {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}, nil); sp != "" {
|
||||
t.Fatalf("préambule non vide sans outils : %q", sp)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ const (
|
||||
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
|
||||
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
||||
// they only appear when the matching feature is on.
|
||||
func baseSystemPrompt(caps Caps) string {
|
||||
//
|
||||
// tools = les outils annoncés sur ce même tour (EnabledTools) : ce qui en est
|
||||
// dit ici (la ligne MCP) en est déduit, jamais relu ailleurs.
|
||||
func baseSystemPrompt(caps Caps, tools []Tool) string {
|
||||
hasMem := caps.Mem != MemOff
|
||||
// No tool access at all → no agentic preamble. A plain chat model told to
|
||||
// "call tools immediately" hallucinates textual tool calls (e.g.
|
||||
@@ -119,10 +122,10 @@ func baseSystemPrompt(caps Caps) string {
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
// Ligne MCP tirée des outils de CE tour (voir mcpPromptLine) : un rôle sans
|
||||
// outil MCP n'en reçoit pas, et le premier tour après un démarrage l'a déjà.
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
b.WriteString(mcpPromptLine(tools))
|
||||
}
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
|
||||
@@ -166,7 +166,8 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
|
||||
}
|
||||
var content strings.Builder
|
||||
sawUsage := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
|
||||
sawUsage = true
|
||||
}
|
||||
|
||||
@@ -233,13 +233,13 @@ func globalCaps() Caps {
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
// preamble + machine briefing (when tools are enabled) and the lightweight
|
||||
// skills directory (when skills are enabled). Merges with an existing system
|
||||
// message if present.
|
||||
func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
// message if present. tools = the tools sent on this same turn (see prepareTurn).
|
||||
func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message {
|
||||
var parts []string
|
||||
// Decisive-agent preamble (anti-loop) — only when the model actually has
|
||||
// tools, otherwise it nudges a plain chat model to "call tools" it doesn't
|
||||
// have, which leaks malformed tool-call text into the answer.
|
||||
if bp := baseSystemPrompt(caps); bp != "" {
|
||||
if bp := baseSystemPrompt(caps, tools); bp != "" {
|
||||
parts = append(parts, bp)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); mp != "" {
|
||||
@@ -260,6 +260,17 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
|
||||
}
|
||||
|
||||
// prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir
|
||||
// d'eux : la séquence et les outils renvoyés vont ensemble à runChatTools.
|
||||
//
|
||||
// Les calculer deux fois (préambule, puis runChat) payait deux fois les
|
||||
// tentatives de connexion MCP d'un serveur en panne, et laissait le préambule
|
||||
// décrire un autre état que les schémas envoyés à côté.
|
||||
func prepareTurn(msgs []Message, caps Caps) ([]Message, []Tool) {
|
||||
tools := EnabledTools(caps)
|
||||
return InjectSkills(msgs, caps, tools), tools
|
||||
}
|
||||
|
||||
// steerSystem ajoute une consigne système SANS jamais créer un second message
|
||||
// système ailleurs qu'en tête : elle est fusionnée dans celui d'ouverture, ou
|
||||
// posée en première position s'il n'y en a pas.
|
||||
@@ -930,8 +941,13 @@ func toolCallLabel(name string, args map[string]any) string {
|
||||
// prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu
|
||||
// d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`.
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
|
||||
return runChatTools(ctx, messages, EnabledTools(caps), temperature, caps, cb, injectQueued...)
|
||||
}
|
||||
|
||||
// runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux
|
||||
// que décrit le préambule, à l'octet près.
|
||||
func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
|
||||
var extra []Message
|
||||
tools := EnabledTools(caps)
|
||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||
// in streaming mode: the model's <think> block (opened by the chat template)
|
||||
// arrives inline in `content`, terminated by a literal </think>. When
|
||||
|
||||
+32
-12
@@ -449,22 +449,42 @@ func flattenMCPContent(res *mcpsdk.CallToolResult) string {
|
||||
return text
|
||||
}
|
||||
|
||||
// mcpPromptLine renvoie une ligne système listant les serveurs MCP connectés et
|
||||
// leur nombre d'outils, pour situer le modèle. Ne force PAS de connexion : lit
|
||||
// seulement l'état déjà établi par mcpTools() (appelé par EnabledTools sur le
|
||||
// même tour), pour ne pas payer deux fois le handshake.
|
||||
func mcpPromptLine() string {
|
||||
mcpMgr.mu.Lock()
|
||||
defer mcpMgr.mu.Unlock()
|
||||
var names []string
|
||||
for name, s := range mcpMgr.sessions {
|
||||
if s.sess != nil && len(s.tools) > 0 {
|
||||
names = append(names, fmt.Sprintf("%s (%d)", name, len(s.tools)))
|
||||
// mcpPromptLine renvoie une ligne système listant les serveurs MCP annoncés et
|
||||
// leur nombre d'outils, pour situer le modèle. Elle se DÉDUIT des outils
|
||||
// réellement envoyés ce tour-ci (EnabledTools), pas de l'état du pool.
|
||||
//
|
||||
// Lue dans le pool, elle arrivait en retard d'un tour : le préambule est bâti
|
||||
// avant que mcpTools n'ouvre les connexions, si bien que le premier tour après
|
||||
// un démarrage partait sans la ligne et le second avec — tout le prompt à
|
||||
// recalculer pour une ligne. Elle comptait aussi les outils masqués par
|
||||
// l'utilisateur, et s'affichait pour un rôle (planner…) qui ne reçoit aucun
|
||||
// outil MCP. Tirée de la même tranche que les schémas, elle ne peut plus les
|
||||
// contredire.
|
||||
func mcpPromptLine(tools []Tool) string {
|
||||
counts := map[string]int{}
|
||||
for _, t := range tools {
|
||||
if !strings.HasPrefix(t.Function.Name, "mcp__") {
|
||||
continue
|
||||
}
|
||||
// mcpTools préfixe chaque description par « [MCP: <serveur>] » : le nom
|
||||
// d'origine du serveur, que le nom d'outil (assaini) ne garde pas.
|
||||
rest, ok := strings.CutPrefix(t.Function.Description, "[MCP: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
server, _, ok := strings.Cut(rest, "] ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
counts[server]++
|
||||
}
|
||||
if len(names) == 0 {
|
||||
if len(counts) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(counts))
|
||||
for name, n := range counts {
|
||||
names = append(names, fmt.Sprintf("%s (%d)", name, n))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return "\n\nMCP servers connected (their tools are prefixed mcp__<server>__<tool>): " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mcpTestTool(server, tool string) Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: mcpExposedName(server, tool),
|
||||
Description: "[MCP: " + server + "] décrit " + tool,
|
||||
}}
|
||||
}
|
||||
|
||||
// La ligne MCP du préambule se déduit des outils envoyés sur le MÊME tour : elle
|
||||
// ne peut ni manquer au premier tour après un démarrage (pool encore vide quand
|
||||
// le préambule était bâti), ni compter un outil masqué, ni apparaître pour un
|
||||
// rôle qui ne reçoit aucun outil MCP.
|
||||
func TestMCPPromptLineDeriveDesOutils(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tools []Tool
|
||||
want string
|
||||
}{
|
||||
{"aucun outil", nil, ""},
|
||||
{"aucun outil MCP", []Tool{bashTool(), readTool()}, ""},
|
||||
{"un serveur", []Tool{bashTool(), mcpTestTool("github", "issues"), mcpTestTool("github", "prs")},
|
||||
"github (2)"},
|
||||
{"plusieurs serveurs, triés", []Tool{mcpTestTool("zeta", "a"), mcpTestTool("alpha.io", "b"), mcpTestTool("zeta", "c")},
|
||||
"alpha.io (1), zeta (2)"},
|
||||
{"description sans le préfixe [MCP: …] ignorée", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "rien"}}},
|
||||
""},
|
||||
{"description vide après le préfixe", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "[MCP: x] "}}},
|
||||
"x (1)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mcpPromptLine(c.tools)
|
||||
if c.want == "" {
|
||||
if got != "" {
|
||||
t.Errorf("%s : ligne %q, attendu aucune", c.name, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(got, ": "+c.want+".") {
|
||||
t.Errorf("%s : ligne %q, attendu la liste %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le préambule et les outils du tour viennent de la même tranche : le rôle
|
||||
// planner (aucun outil MCP) n'a pas de ligne MCP, le fil principal l'a dès le
|
||||
// premier tour.
|
||||
func TestBaseSystemPromptLigneMCPSuitLesOutils(t *testing.T) {
|
||||
caps := Caps{Agent: true, Mem: MemOff}
|
||||
with := append(EnabledTools(caps), mcpTestTool("github", "issues"))
|
||||
if sp := baseSystemPrompt(caps, with); !strings.Contains(sp, "MCP servers connected") || !strings.Contains(sp, "github (1)") {
|
||||
t.Fatalf("ligne MCP absente alors qu'un outil MCP part sur ce tour :\n%s", sp)
|
||||
}
|
||||
if sp := baseSystemPrompt(caps, EnabledTools(caps)); strings.Contains(sp, "MCP servers") {
|
||||
t.Fatalf("ligne MCP sans aucun outil MCP envoyé :\n%s", sp)
|
||||
}
|
||||
planner := Caps{Agent: true, Code: true, Role: "planner", Mem: MemOff}
|
||||
if sp := baseSystemPrompt(planner, EnabledTools(planner)); strings.Contains(sp, "MCP servers") {
|
||||
t.Fatalf("le planner ne reçoit aucun outil MCP, sa ligne ne doit pas l'annoncer :\n%s", sp)
|
||||
}
|
||||
}
|
||||
|
||||
// prepareTurn rend la séquence ET les outils qu'elle décrit : le préambule
|
||||
// injecté est exactement celui de baseSystemPrompt sur ces outils.
|
||||
func TestPrepareTurnPreambuleCoherentAvecOutils(t *testing.T) {
|
||||
testHome(t)
|
||||
caps := Caps{Agent: true, Mem: MemOff}
|
||||
sent, tools := prepareTurn([]Message{{Role: "user", Content: "salut"}}, caps)
|
||||
if len(sent) != 2 || sent[0].Role != "system" {
|
||||
t.Fatalf("séquence inattendue : %#v", sent)
|
||||
}
|
||||
sys, _ := sent[0].Content.(string)
|
||||
if !strings.HasPrefix(sys, baseSystemPrompt(caps, tools)) {
|
||||
t.Fatalf("le préambule ne correspond pas aux outils renvoyés :\n%s", sys)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
|
||||
{Role: "system", Content: projectContextPrefix + " — projet A"},
|
||||
{Role: "user", Content: "bonjour"},
|
||||
}
|
||||
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
|
||||
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}, nil))
|
||||
if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
|
||||
t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
|
||||
}
|
||||
|
||||
@@ -69,9 +69,11 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
|
||||
temperature = 0.7
|
||||
}
|
||||
// La note de tâche (conscience du mode autonome + compte-rendu du passage
|
||||
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
|
||||
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
|
||||
// de prompt de llama-server sert aux deux. Une note dans le système faisait
|
||||
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : elle
|
||||
// n'ajoute aucun écart au préfixe système + outils. Il n'est pas pour autant
|
||||
// celui du chat : le briefing machine porte le dossier de travail de la
|
||||
// tâche, et taskCaps ne reprend ni le pilotage du navigateur ni une mémoire
|
||||
// coupée. Une note dans le système faisait
|
||||
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
|
||||
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
|
||||
if note := taskContextNote(taskName, lastReport); note != "" {
|
||||
@@ -79,7 +81,7 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
|
||||
}
|
||||
msgs := []Message{{Role: "user", Content: prompt}}
|
||||
// Même préambule que la vraie génération : consigne personnelle + préambule
|
||||
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
||||
// agent + briefing machine (via prepareTurn), pour que l'IA ait le même
|
||||
// contexte et les mêmes outils qu'en chat.
|
||||
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
|
||||
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
|
||||
@@ -93,7 +95,8 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
|
||||
// La tâche a pris le slot de la conversation : effacé une fois la tâche
|
||||
// finie, verrou de génération encore tenu (llm_slots.go).
|
||||
defer engineSideJob()()
|
||||
_, err := runChat(withPerf(ctx, perfTask, "task:"+taskID), InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
_, err := runChatTools(withPerf(ctx, perfTask, "task:"+taskID), sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,19 @@ func trackerList() []trackerMeta {
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].LastTS > out[j].LastTS })
|
||||
// Départage par nom puis slug : allKV parcourt une map, et deux trackers au
|
||||
// même horodatage (deux points datés du même jour, sans heure) sortaient dans
|
||||
// un ordre tiré au sort. Cette liste part dans le contexte du modèle — un ordre qui
|
||||
// change d'un tour à l'autre change le prompt, et tout le cache derrière.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].LastTS != out[j].LastTS {
|
||||
return out[i].LastTS > out[j].LastTS
|
||||
}
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Slug < out[j].Slug
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -289,3 +289,32 @@ func TestStrTolerantAuType(t *testing.T) {
|
||||
t.Fatalf("str(\"texte\") = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux trackers au même horodatage (points datés du même jour, sans heure)
|
||||
// sortent toujours dans le même ordre — par nom — quel que soit l'ordre de
|
||||
// parcours du stockage. La liste part dans le contexte du modèle : un ordre
|
||||
// tiré au sort changerait le prompt d'un tour à l'autre.
|
||||
func TestTrackerListOrdreStableAEgalite(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
for _, name := range []string{"zinc", "azote", "magnésium"} {
|
||||
if _, err := trackerAdd(name, "2026-05-04", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := trackerAdd("récent", "2026-06-01", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"récent", "azote", "magnésium", "zinc"}
|
||||
for round := 0; round < 20; round++ {
|
||||
list := trackerList()
|
||||
var got []string
|
||||
for _, m := range list {
|
||||
got = append(got, m.Name)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("ordre %v, attendu %v (le plus récent d'abord, puis par nom)", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user