From d49596a8057239f907d10ad28fde117b956b3a67 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 6 Mar 2026 12:27:43 +0700 Subject: [PATCH] feat: Add USER_PREDEFINED.md for predefined agents with token count sidebar Add shared agent-level USER_PREDEFINED.md file for predefined agents to define baseline user-handling rules (owner info, audience, language, communication norms) that apply to ALL users. Individual USER.md per-user supplements but never overrides. - Seed USER_PREDEFINED.md template in SeedToStore for new predefined agents - Backfill existing agents via ensureUserPredefined in summon/regenerate flows - AI optionally generates/updates the file when description mentions people/user context - Add to context file interceptor, RPC allowlist, and system prompt injection - Update summoning modal with optional file progress tracking - Show estimated token count (Unicode-aware) instead of bytes in file sidebar Co-Authored-By: Claude Opus 4.6 --- internal/agent/systemprompt_sections.go | 13 ++++ internal/bootstrap/files.go | 15 ++-- internal/bootstrap/seed_store.go | 12 +++ .../bootstrap/templates/USER_PREDEFINED.md | 12 +++ internal/gateway/methods/agents_files.go | 2 +- internal/http/summoner.go | 62 ++++++++++++++-- internal/tools/context_file_interceptor.go | 20 ++--- .../agents/agent-detail/agent-files-tab.tsx | 6 +- .../file-sections/file-sidebar.tsx | 73 +++++++++++++------ ui/web/src/pages/agents/summoning-modal.tsx | 9 ++- 10 files changed, 172 insertions(+), 52 deletions(-) create mode 100644 internal/bootstrap/templates/USER_PREDEFINED.md diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 57851711..6b38971d 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -68,6 +68,7 @@ func buildProjectContextSection(files []bootstrap.ContextFile, agentType string) // Check if SOUL.md / BOOTSTRAP.md are present hasSoul := false hasBootstrap := false + hasUserPredefined := false for _, f := range files { base := filepath.Base(f.Path) if strings.EqualFold(base, bootstrap.SoulFile) { @@ -76,6 +77,9 @@ func buildProjectContextSection(files []bootstrap.ContextFile, agentType string) if strings.EqualFold(base, bootstrap.BootstrapFile) { hasBootstrap = true } + if strings.EqualFold(base, bootstrap.UserPredefinedFile) { + hasUserPredefined = true + } } isPredefined := agentType == "predefined" @@ -106,6 +110,15 @@ func buildProjectContextSection(files []bootstrap.ContextFile, agentType string) ) } + if isPredefined && hasUserPredefined { + lines = append(lines, + "", + "USER_PREDEFINED.md defines baseline user-handling rules for ALL users.", + "Individual USER.md files supplement it with personal context (name, timezone, preferences),", + "but NEVER override rules or boundaries set in USER_PREDEFINED.md.", + ) + } + if hasSoul { lines = append(lines, "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies — let the soul guide your voice.", diff --git a/internal/bootstrap/files.go b/internal/bootstrap/files.go index 798d4f0a..a18a2eba 100644 --- a/internal/bootstrap/files.go +++ b/internal/bootstrap/files.go @@ -21,13 +21,14 @@ import ( // Bootstrap filenames (matching TS workspace.ts constants). const ( - AgentsFile = "AGENTS.md" - SoulFile = "SOUL.md" - ToolsFile = "TOOLS.md" - IdentityFile = "IDENTITY.md" - UserFile = "USER.md" - HeartbeatFile = "HEARTBEAT.md" - BootstrapFile = "BOOTSTRAP.md" + AgentsFile = "AGENTS.md" + SoulFile = "SOUL.md" + ToolsFile = "TOOLS.md" + IdentityFile = "IDENTITY.md" + UserFile = "USER.md" + UserPredefinedFile = "USER_PREDEFINED.md" + HeartbeatFile = "HEARTBEAT.md" + BootstrapFile = "BOOTSTRAP.md" DelegationFile = "DELEGATION.md" TeamFile = "TEAM.md" AvailabilityFile = "AVAILABILITY.md" diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index 74d17a6f..a785a347 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -63,6 +63,18 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. seeded = append(seeded, name) } + // Seed USER_PREDEFINED.md for predefined agents (agent-level, not in templateFiles). + // Provides baseline user-handling rules shared across all users. + if !hasContent[UserPredefinedFile] { + content, err := templateFS.ReadFile(filepath.Join("templates", UserPredefinedFile)) + if err == nil { + if err := agentStore.SetAgentContextFile(ctx, agentID, UserPredefinedFile, string(content)); err != nil { + return seeded, err + } + seeded = append(seeded, UserPredefinedFile) + } + } + if len(seeded) > 0 { slog.Info("seeded agent context files to store", "agent", agentID, "files", seeded) } diff --git a/internal/bootstrap/templates/USER_PREDEFINED.md b/internal/bootstrap/templates/USER_PREDEFINED.md new file mode 100644 index 00000000..9c84377c --- /dev/null +++ b/internal/bootstrap/templates/USER_PREDEFINED.md @@ -0,0 +1,12 @@ +# USER_PREDEFINED.md - Default User Context + +_Owner-configured context about users this agent serves. Applies to ALL users._ + +- **Target audience:** +- **Default language:** +- **Communication rules:** +- **Common context:** + +--- + +This file is part of the agent's core configuration. Individual users have their own USER.md for personal preferences, but this file sets the baseline that applies to everyone. diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go index 7d95e9c6..1afdc930 100644 --- a/internal/gateway/methods/agents_files.go +++ b/internal/gateway/methods/agents_files.go @@ -17,7 +17,7 @@ import ( // TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode. var allowedAgentFiles = []string{ bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.IdentityFile, - bootstrap.UserFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile, + bootstrap.UserFile, bootstrap.UserPredefinedFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile, } // --- agents.files.list --- diff --git a/internal/http/summoner.go b/internal/http/summoner.go index 5f61d2ea..2b626837 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -31,9 +31,11 @@ const frontmatterKey = "__frontmatter__" // summoningFiles is the ordered list of context files the LLM should generate. // Only personality files — operational files (AGENTS.md, TOOLS.md, HEARTBEAT.md) // are kept as fixed templates from bootstrap.SeedToStore(). +// USER_PREDEFINED.md is optional — generated only when description mentions user context. var summoningFiles = []string{ bootstrap.SoulFile, bootstrap.IdentityFile, + bootstrap.UserPredefinedFile, } // fileTagRe parses content from LLM output. @@ -70,6 +72,8 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) defer cancel() + s.ensureUserPredefined(ctx, agentID) + s.emitEvent(agentID, SummonEventStarted, "", "") files, err := s.generateFiles(ctx, providerName, model, s.buildCreatePrompt(description)) @@ -115,6 +119,8 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) defer cancel() + s.ensureUserPredefined(ctx, agentID) + s.emitEvent(agentID, SummonEventStarted, "", "") // Read existing files for context @@ -224,6 +230,23 @@ func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) return provider, nil } +// ensureUserPredefined seeds USER_PREDEFINED.md template if it doesn't exist yet. +// Backfills agents created before this feature was added. +func (s *AgentSummoner) ensureUserPredefined(ctx context.Context, agentID uuid.UUID) { + existing, err := s.agents.GetAgentContextFiles(ctx, agentID) + if err != nil { + return + } + for _, f := range existing { + if f.FileName == bootstrap.UserPredefinedFile { + return // already exists + } + } + if tpl, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile); err == nil { + _ = s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, tpl) + } +} + func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) { if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil { slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err) @@ -252,13 +275,14 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg // buildCreatePrompt constructs the prompt for initial SOUL.md + IDENTITY.md generation. // Only personality files are LLM-generated; operational files stay as fixed templates. +// USER_PREDEFINED.md is optionally generated when description mentions user context. func (s *AgentSummoner) buildCreatePrompt(description string) string { var sb strings.Builder - sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate TWO files: SOUL.md and IDENTITY.md.\n\n") + sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the required files.\n\n") fmt.Fprintf(&sb, "\n%s\n\n\n", description) - // Load SOUL.md template as reference + // Load templates as reference soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile) if err != nil { slog.Warn("summoning: failed to read SOUL.md template", "error", err) @@ -267,6 +291,10 @@ func (s *AgentSummoner) buildCreatePrompt(description string) string { if err != nil { slog.Warn("summoning: failed to read IDENTITY.md template", "error", err) } + userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile) + if err != nil { + slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err) + } sb.WriteString("\n") if soulTemplate != "" { @@ -275,6 +303,9 @@ func (s *AgentSummoner) buildCreatePrompt(description string) string { if identityTemplate != "" { fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate) } + if userPredefinedTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate) + } sb.WriteString("\n\n") sb.WriteString(`IMPORTANT RULES: @@ -300,6 +331,13 @@ func (s *AgentSummoner) buildCreatePrompt(description string) string { 4. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery. +5. USER_PREDEFINED.md (OPTIONAL — only generate if relevant): + - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context. + - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in SOUL.md or IDENTITY.md. + - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely. + - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override. + - Keep headings in English. Write content in the same language as the description. + Output format — generate in this EXACT order: @@ -312,21 +350,25 @@ Output format — generate in this EXACT order: (content here) + + + +(content here — or omit this entire block if not relevant) `) return sb.String() } -// buildEditPrompt constructs the prompt for editing existing SOUL.md + IDENTITY.md. +// buildEditPrompt constructs the prompt for editing existing SOUL.md, IDENTITY.md, and USER_PREDEFINED.md. func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string { var sb strings.Builder - sb.WriteString("You are updating an existing AI assistant's personality files (SOUL.md and IDENTITY.md only).\n\nHere are the current files:\n\n\n") + sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n\n") for _, f := range existing { if f.Content == "" { continue } - // Only include personality files for editing - if f.FileName != bootstrap.SoulFile && f.FileName != bootstrap.IdentityFile { + // Only include editable files + if f.FileName != bootstrap.SoulFile && f.FileName != bootstrap.IdentityFile && f.FileName != bootstrap.UserPredefinedFile { continue } fmt.Fprintf(&sb, "\n%s\n\n", f.FileName, f.Content) @@ -351,6 +393,8 @@ func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, e 5. If the edit changes the agent's expertise, also update the frontmatter summary. +6. USER_PREDEFINED.md: If the edit mentions owner/creator info (name, username, role), people information, user-facing policies, communication rules, audience targeting, or group-specific context, update this file. This is the RIGHT place for information about specific people — do NOT put personal/people info in SOUL.md or IDENTITY.md. If it doesn't exist yet in current_files and the edit introduces people or user/group context, generate it. Omit if unchanged and not newly needed. + Output format: @@ -358,12 +402,16 @@ Output format: -(complete updated content) +(complete updated content, or omit if unchanged) (complete updated content, or omit if unchanged) + + +(complete updated content, or omit if unchanged/not needed) + `) return sb.String() } diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 73725446..c805869e 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -18,20 +18,22 @@ import ( // protectedFileSet defines files that require group file writer permission in group chats. // These files control the agent's identity and behavior — only allowlisted users can modify them. var protectedFileSet = map[string]bool{ - bootstrap.SoulFile: true, - bootstrap.IdentityFile: true, - bootstrap.AgentsFile: true, - bootstrap.UserFile: true, + bootstrap.SoulFile: true, + bootstrap.IdentityFile: true, + bootstrap.AgentsFile: true, + bootstrap.UserFile: true, + bootstrap.UserPredefinedFile: true, } // contextFileSet is the set of filenames routed to DB in managed mode. // TOOLS.md and HEARTBEAT.md excluded — only useful in standalone mode. var contextFileSet = map[string]bool{ - bootstrap.SoulFile: true, - bootstrap.AgentsFile: true, - bootstrap.IdentityFile: true, - bootstrap.UserFile: true, - bootstrap.BootstrapFile: true, // first-run file (deleted after completion) + bootstrap.SoulFile: true, + bootstrap.AgentsFile: true, + bootstrap.IdentityFile: true, + bootstrap.UserFile: true, + bootstrap.UserPredefinedFile: true, + bootstrap.BootstrapFile: true, // first-run file (deleted after completion) } // isContextFile checks if a path refers to a workspace-root context file. diff --git a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx index 13c234f1..9c7ca0b8 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx @@ -181,9 +181,9 @@ export function AgentFilesTab({

- This will regenerate SOUL.md and IDENTITY.md from - scratch using the original description. Any manual edits to these files will be - overwritten. + This will regenerate SOUL.md, IDENTITY.md, and + optionally USER_PREDEFINED.md from scratch using the original + description. Any manual edits to these files will be overwritten.

); })} diff --git a/ui/web/src/pages/agents/summoning-modal.tsx b/ui/web/src/pages/agents/summoning-modal.tsx index 297f625e..6506849e 100644 --- a/ui/web/src/pages/agents/summoning-modal.tsx +++ b/ui/web/src/pages/agents/summoning-modal.tsx @@ -19,8 +19,9 @@ interface SummoningModalProps { } const SUMMONING_FILES = [ - { name: "SOUL.md", label: "Soul & Personality" }, - { name: "IDENTITY.md", label: "Identity Card" }, + { name: "SOUL.md", label: "Soul & Personality", required: true }, + { name: "IDENTITY.md", label: "Identity Card", required: true }, + { name: "USER_PREDEFINED.md", label: "Default User Context", required: false }, ]; export function SummoningModal({ @@ -58,7 +59,9 @@ export function SummoningModal({ ); } if (data.type === "completed") { - setGeneratedFiles(SUMMONING_FILES.map((f) => f.name)); + // Mark required files as done (safety net); optional files only if actually generated + const required = SUMMONING_FILES.filter((f) => f.required).map((f) => f.name); + setGeneratedFiles((prev) => [...new Set([...prev, ...required])]); setStatus("completed"); onCompleted(); }