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 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-03-06 12:27:43 +07:00
1 parent 4e7d55ff37
commit d49596a805
10 files changed
+172 -52

No files matched your search

+13
View File
@@ -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.",
+8 -7
View File
@@ -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"
+12
View File
@@ -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)
}
@@ -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.
+1 -1
View File
@@ -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 ---
+55 -7
View File
@@ -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 <file name="SOUL.md">content</file> 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, "<description>\n%s\n</description>\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("<templates>\n")
if soulTemplate != "" {
@@ -275,6 +303,9 @@ func (s *AgentSummoner) buildCreatePrompt(description string) string {
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
if userPredefinedTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"USER_PREDEFINED.md\">\n%s\n</file>\n", userPredefinedTemplate)
}
sb.WriteString("</templates>\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:
<frontmatter>
@@ -312,21 +350,25 @@ Output format — generate in this EXACT order:
<file name="IDENTITY.md">
(content here)
</file>
<file name="USER_PREDEFINED.md">
(content here — or omit this entire block if not relevant)
</file>`)
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<current_files>\n")
sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n<current_files>\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, "<file name=%q>\n%s\n</file>\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:
<frontmatter>
@@ -358,12 +402,16 @@ Output format:
</frontmatter>
<file name="SOUL.md">
(complete updated content)
(complete updated content, or omit if unchanged)
</file>
<file name="IDENTITY.md">
(complete updated content, or omit if unchanged)
</file>
<file name="USER_PREDEFINED.md">
(complete updated content, or omit if unchanged/not needed)
</file>
`)
return sb.String()
}
+11 -9
View File
@@ -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.
@@ -181,9 +181,9 @@ export function AgentFilesTab({
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This will regenerate <strong>SOUL.md</strong> and <strong>IDENTITY.md</strong> from
scratch using the original description. Any manual edits to these files will be
overwritten.
This will regenerate <strong>SOUL.md</strong>, <strong>IDENTITY.md</strong>, and
optionally <strong>USER_PREDEFINED.md</strong> from scratch using the original
description. Any manual edits to these files will be overwritten.
</p>
<DialogFooter>
<Button
@@ -2,6 +2,33 @@ import { FileText } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { BootstrapFile } from "@/types/agent";
/**
* Estimate token count from text content (Unicode-aware).
* Splits into ASCII and non-ASCII segments for better accuracy:
* - ASCII: ~4 chars per token (English average)
* - Non-ASCII (Vietnamese diacritics, CJK, emoji): ~1.5 chars per token
*/
function estimateTokensFromContent(content: string): number {
let ascii = 0;
let nonAscii = 0;
for (const ch of content) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (ch.codePointAt(0)! < 128) ascii++;
else nonAscii++;
}
return Math.max(1, Math.ceil(ascii / 4 + nonAscii / 1.5));
}
/** Estimate token count from UTF-8 byte size (fallback when content is unavailable). */
function estimateTokensFromBytes(bytes: number): number {
return Math.max(1, Math.ceil(bytes / 4));
}
function formatTokenCount(tokens: number): string {
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
interface FileSidebarProps {
files: BootstrapFile[];
selectedFile: string | null;
@@ -16,7 +43,7 @@ export function FileSidebar({
isUserScoped,
}: FileSidebarProps) {
return (
<div className="w-48 shrink-0 overflow-y-auto rounded-lg bg-muted/40 p-2">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg bg-muted/40 p-2">
<div className="space-y-0.5">
{files.map((file) => {
const userScoped = isUserScoped(file.name);
@@ -31,29 +58,31 @@ export function FileSidebar({
userScoped
? "cursor-not-allowed opacity-50"
: active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground"
? "bg-background text-foreground shadow-sm cursor-pointer"
: "text-foreground hover:bg-background/60 cursor-pointer"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">
{file.name}
</span>
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">
per-user
</Badge>
) : file.missing ? (
<span className="shrink-0 text-[10px] text-muted-foreground/60">
empty
</span>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{file.size > 1024
? `${(file.size / 1024).toFixed(1)}K`
: `${file.size}B`}
</span>
)}
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 self-start" />
<div className="min-w-0 flex-1 text-left">
<div className="truncate">{file.name}</div>
{userScoped ? (
<Badge variant="outline" className="mt-0.5 text-[10px]">
per-user
</Badge>
) : file.missing ? (
<span className="text-[10px] text-muted-foreground/60">
empty
</span>
) : (
<div className="text-[10px] text-muted-foreground/60">
Est {formatTokenCount(
file.content
? estimateTokensFromContent(file.content)
: estimateTokensFromBytes(file.size),
)} tokens
</div>
)}
</div>
</button>
);
})}
+6 -3
View File
@@ -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();
}