fix(bootstrap): ensure per-user context files are seeded for all agent types

- Separate file seeding from workspace resolution so agents without
  workspace still get BOOTSTRAP.md and USER.md seeded
- Always seed context files for existing profiles that have zero files
  (handles EnsureUserProfile pre-creation via HTTP API)
- Add persistent "USER PROFILE INCOMPLETE" nudge in system prompt when
  BOOTSTRAP.md is cleaned up but USER.md remains blank
- Move bootstrap auto-cleanup nudge before session flush so the reminder
  is persisted to history
- Add userFilesSeeded sync.Map to avoid redundant seeding calls
- Capture workspace from seeding call to eliminate double DB roundtrip
This commit is contained in:
viettranx committed 2026-03-27 18:19:53 +07:00
1 parent 343ad0c2b1
commit 23c43259c9
6 files changed
+92 -40

No files matched your search

+21 -5
View File
@@ -22,12 +22,30 @@ func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermi
if err != nil {
return effectiveWs, err
}
// Seed context files:
// - isNew: always seed (first-time user)
// - !isNew: only seed when user has NO context files at all.
// This handles the EnsureUserProfile (HTTP API) pre-creation case where profile
// exists but context files were never seeded. We check for zero files rather than
// calling SeedUserFiles unconditionally to avoid re-seeding BOOTSTRAP.md after
// auto-cleanup (which DELETEs the row — SeedUserFiles would treat it as missing).
needSeed := isNew
if !isNew {
return effectiveWs, nil // already profiled = already seeded
existing, qErr := as.GetUserContextFiles(ctx, agentID, userID)
if qErr == nil && len(existing) == 0 {
needSeed = true
}
}
if needSeed {
if _, seedErr := bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType); seedErr != nil {
slog.Warn("failed to seed user context files", "error", seedErr, "agent", agentID, "user", userID)
}
}
// Auto-add first group member as a file writer (bootstrap the allowlist).
if configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
// Only needed for truly new profiles — existing groups already have their writers.
if isNew && configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
senderID := store.SenderIDFromContext(ctx)
if senderID != "" {
parts := strings.SplitN(senderID, "|", 2)
@@ -47,12 +65,10 @@ func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermi
}); addErr != nil {
slog.Warn("failed to auto-add group file writer", "error", addErr, "sender", numericID, "group", userID)
}
// No bus broadcast needed — Grant already invalidates cache
}
}
_, err = bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType)
return effectiveWs, err
return effectiveWs, nil
}
}
+26 -12
View File
@@ -97,24 +97,38 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
}
// --- Workspace resolution (layered pipeline) ---
// --- Per-user file seeding + workspace resolution ---
// Seeding must run before buildMessages→resolveContextFiles reads context files.
// Team sessions skip: members process tasks from leader, not end-user onboarding.
// SeedUserFiles is idempotent — safe to call even if files already exist.
// ensureUserFiles both seeds files AND returns effective workspace, so we capture
// the workspace here to avoid a second DB call in the workspace resolution block.
isTeamSession := bootstrap.IsTeamSession(req.SessionKey)
var ensuredWs string // workspace path returned by ensureUserFiles (empty if not called)
if req.UserID != "" && l.ensureUserFiles != nil && !isTeamSession {
if _, seeded := l.userFilesSeeded.Load(req.UserID); !seeded {
ws, err := l.ensureUserFiles(ctx, l.agentUUID, req.UserID, l.agentType, l.workspace, req.Channel)
if err != nil {
slog.Warn("failed to ensure user context files", "error", err)
} else {
ensuredWs = ws
}
l.userFilesSeeded.Store(req.UserID, true)
}
}
// Workspace resolution (layered pipeline).
// Layer order: tenant → team → project (future) → user/chat
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
// Result is always a single folder set via WithToolWorkspace.
// Solo agent workspace: resolve base from user profile or agent config.
isTeamSession := bootstrap.IsTeamSession(req.SessionKey)
if l.workspace != "" && req.UserID != "" {
cachedWs, loaded := l.userWorkspaces.Load(req.UserID)
if !loaded {
ws := l.workspace
if l.ensureUserFiles != nil && !isTeamSession {
var err error
ws, err = l.ensureUserFiles(ctx, l.agentUUID, req.UserID, l.agentType, l.workspace, req.Channel)
if err != nil {
slog.Warn("failed to ensure user context files", "error", err)
ws = l.workspace
}
// Use workspace from ensureUserFiles if available (avoids second DB call),
// otherwise fall back to agent's default workspace.
ws := ensuredWs
if ws == "" {
ws = l.workspace
}
ws = config.ExpandHome(ws)
if !filepath.IsAbs(ws) {
+23 -22
View File
@@ -130,30 +130,9 @@ func (l *Loop) finalizeRun(
}
}
// Flush all buffered messages to session atomically.
for _, msg := range rs.pendingMsgs {
l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(rs.totalUsage.PromptTokens), int64(rs.totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
if rs.totalUsage.PromptTokens > 0 {
msgCount := len(history) + rs.checkpointFlushedMsgs + len(rs.pendingMsgs)
l.sessions.SetLastPromptTokens(ctx, req.SessionKey, rs.totalUsage.PromptTokens, msgCount)
}
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md.
// If USER.md is still the blank template, inject a reminder so the agent fills it.
// Must run BEFORE session flush so the nudge message is persisted to history.
if hadBootstrap && l.bootstrapCleanup != nil {
userTurns := 1 // current user message
for _, m := range history {
@@ -183,6 +162,28 @@ func (l *Loop) finalizeRun(
}
}
// Flush all buffered messages to session atomically.
for _, msg := range rs.pendingMsgs {
l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(rs.totalUsage.PromptTokens), int64(rs.totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
if rs.totalUsage.PromptTokens > 0 {
msgCount := len(history) + rs.checkpointFlushedMsgs + len(rs.pendingMsgs)
l.sessions.SetLastPromptTokens(ctx, req.SessionKey, rs.totalUsage.PromptTokens, msgCount)
}
l.sessions.Save(ctx, req.SessionKey)
// 8. Metadata Stripping: Clean internal [[...]] tags for user-facing content
rs.finalContent = StripMessageDirectives(rs.finalContent)
if isSilent {
+2 -1
View File
@@ -79,7 +79,8 @@ type Loop struct {
ensureUserFiles EnsureUserFilesFunc
contextFileLoader ContextFileLoaderFunc
bootstrapCleanup BootstrapCleanupFunc
userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles)
userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles)
userFilesSeeded sync.Map // userID → bool (whether SeedUserFiles has been called this instance)
// Compaction config (memory flush settings)
compactionCfg *config.CompactionConfig
+10
View File
@@ -169,6 +169,16 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
"If the user's first message contains enough info (name, language, timezone), write USER.md immediately — do NOT wait for multiple turns.",
"",
)
} else if content := findContextFileContent(cfg.ContextFiles, bootstrap.UserFile); content != "" && !isUserFilePopulated(content) {
// BOOTSTRAP.md already cleaned up but USER.md is still blank — persistent nudge
lines = append(lines,
"## USER PROFILE INCOMPLETE",
"",
"USER.md exists but hasn't been filled in yet.",
"During conversation, naturally learn the user's name, language, and timezone.",
"Once you have this info, silently call write_file to update USER.md with their details.",
"",
)
}
// 1.7. # Persona — SOUL.md + IDENTITY.md injected early (primacy zone)
+10
View File
@@ -356,6 +356,16 @@ func hasBootstrapFile(files []bootstrap.ContextFile) bool {
return false
}
// findContextFileContent returns the content of a context file by name, or "" if not found.
func findContextFileContent(files []bootstrap.ContextFile, name string) string {
for _, f := range files {
if f.Path == name {
return f.Content
}
}
return ""
}
// hasTeamWorkspace checks if team_tasks is in the tool list (indicates team context).
func hasTeamWorkspace(toolNames []string) bool {
return slices.Contains(toolNames, "team_tasks")