diff --git a/cmd/gateway_callbacks.go b/cmd/gateway_callbacks.go index 30da4f66..af924db2 100644 --- a/cmd/gateway_callbacks.go +++ b/cmd/gateway_callbacks.go @@ -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 } } diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 039b75cb..751d9b3a 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -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) { diff --git a/internal/agent/loop_finalize.go b/internal/agent/loop_finalize.go index ddaf0a4f..d5a1f112 100644 --- a/internal/agent/loop_finalize.go +++ b/internal/agent/loop_finalize.go @@ -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 { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index e7765a32..cb705200 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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 diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index bccf72b1..e2fbed79 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -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) diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 3e8c93dd..c5f47fb4 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -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")