mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
refactor(bootstrap): separate profile and seeding callbacks, consolidate per-user state
- Split EnsureUserFilesFunc into EnsureUserProfileFunc (profile + workspace) and SeedUserFilesFunc (context file seeding) for single-responsibility - Merge userWorkspaces + userFilesSeeded sync.Maps into unified userSetups struct to prevent desync between workspace and seeding state - Add skipIfAnyExist param to SeedUserFiles to encapsulate the "seed only for brand-new users" logic within the bootstrap package - Extract getOrCreateUserSetup helper for clean per-user initialization - Add bootstrap state tests covering all 4 system prompt branches - Keep legacy EnsureUserFilesFunc as fallback for backward compatibility
This commit is contained in:
1 parent
23c43259c9
commit
d3bf16d2de
10 files changed
+365
-94
No files matched your search
+18
-26
@@ -14,33 +14,14 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// buildEnsureUserFiles creates the per-user file seeding callback.
|
||||
// Seeds per-user context files on first chat (new user profile).
|
||||
func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserFilesFunc {
|
||||
return func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (string, error) {
|
||||
// buildEnsureUserProfile creates the user profile resolution callback.
|
||||
// Creates/resolves user profile and returns effective workspace.
|
||||
// Separated from seeding to allow independent lifecycle management.
|
||||
func buildEnsureUserProfile(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserProfileFunc {
|
||||
return func(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (string, bool, error) {
|
||||
isNew, effectiveWs, err := as.GetOrCreateUserProfile(ctx, agentID, userID, workspace, channel)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
return effectiveWs, false, err
|
||||
}
|
||||
|
||||
// Auto-add first group member as a file writer (bootstrap the allowlist).
|
||||
@@ -68,7 +49,18 @@ func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermi
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveWs, nil
|
||||
return effectiveWs, isNew, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildSeedUserFiles creates the context file seeding callback.
|
||||
// Seeds BOOTSTRAP.md, USER.md, etc. into user_context_files.
|
||||
// isNew=true seeds all files; isNew=false only seeds if user has zero files
|
||||
// (avoids re-seeding BOOTSTRAP.md after auto-cleanup on server restart).
|
||||
func buildSeedUserFiles(as store.AgentStore) agent.SeedUserFilesFunc {
|
||||
return func(ctx context.Context, agentID uuid.UUID, userID, agentType string, isNew bool) error {
|
||||
_, err := bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType, !isNew)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,10 +90,12 @@ func wireExtras(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. User seeding callback: seeds per-user context files on first chat
|
||||
var ensureUserFiles agent.EnsureUserFilesFunc
|
||||
// 2. Per-user profile + context file seeding callbacks
|
||||
var ensureUserProfile agent.EnsureUserProfileFunc
|
||||
var seedUserFiles agent.SeedUserFilesFunc
|
||||
if stores.Agents != nil {
|
||||
ensureUserFiles = buildEnsureUserFiles(stores.Agents, stores.ConfigPermissions)
|
||||
ensureUserProfile = buildEnsureUserProfile(stores.Agents, stores.ConfigPermissions)
|
||||
seedUserFiles = buildSeedUserFiles(stores.Agents)
|
||||
}
|
||||
|
||||
// 3. Context file loader callback: loads per-user context files dynamically
|
||||
@@ -138,7 +140,8 @@ func wireExtras(
|
||||
SkillAccessStore: skillAccessStore,
|
||||
HasMemory: hasMemory,
|
||||
TraceCollector: traceCollector,
|
||||
EnsureUserFiles: ensureUserFiles,
|
||||
EnsureUserProfile: ensureUserProfile,
|
||||
SeedUserFiles: seedUserFiles,
|
||||
ContextFileLoader: contextFileLoader,
|
||||
BootstrapCleanup: buildBootstrapCleanup(stores.Agents),
|
||||
InjectionAction: injectionAction,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -97,49 +96,25 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
|
||||
}
|
||||
|
||||
// --- Per-user file seeding + workspace resolution ---
|
||||
// --- Per-user setup: file seeding + workspace resolution ---
|
||||
// Uses userSetups sync.Map to track both concerns atomically per user.
|
||||
// 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.
|
||||
// Team sessions skip seeding: members process tasks from leader, not end-user onboarding.
|
||||
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)
|
||||
}
|
||||
}
|
||||
setup := l.getOrCreateUserSetup(ctx, req.UserID, req.Channel, isTeamSession)
|
||||
|
||||
// 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.
|
||||
if l.workspace != "" && req.UserID != "" {
|
||||
cachedWs, loaded := l.userWorkspaces.Load(req.UserID)
|
||||
if !loaded {
|
||||
// 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) {
|
||||
ws, _ = filepath.Abs(ws)
|
||||
}
|
||||
l.userWorkspaces.Store(req.UserID, ws)
|
||||
cachedWs = ws
|
||||
ws := setup.workspace
|
||||
if ws == "" {
|
||||
ws = l.workspace
|
||||
}
|
||||
// Apply user isolation layer via pipeline.
|
||||
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
|
||||
effectiveWorkspace := tools.ResolveWorkspace(cachedWs.(string),
|
||||
effectiveWorkspace := tools.ResolveWorkspace(ws,
|
||||
tools.UserChatLayer(tools.SanitizePathSegment(req.UserID), shared),
|
||||
)
|
||||
if l.shouldShareMemory() {
|
||||
|
||||
@@ -92,20 +92,20 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
_, hasKG := l.tools.Get("knowledge_graph_search")
|
||||
|
||||
// Per-user workspace: show the user's subdirectory in the system prompt.
|
||||
// Uses cached workspace from user_agent_profiles (includes channel isolation).
|
||||
// Uses cached workspace from userSetups (includes channel isolation).
|
||||
// When workspace sharing is enabled, show the base workspace without user subfolder.
|
||||
promptWorkspace := l.workspace
|
||||
if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" {
|
||||
shared := l.shouldShareWorkspace(userID, peerKind)
|
||||
if cachedWs, ok := l.userWorkspaces.Load(userID); ok {
|
||||
promptWorkspace = tools.ResolveWorkspace(cachedWs.(string),
|
||||
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
|
||||
)
|
||||
} else {
|
||||
promptWorkspace = tools.ResolveWorkspace(l.workspace,
|
||||
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
|
||||
)
|
||||
baseWs := l.workspace
|
||||
if val, ok := l.userSetups.Load(userID); ok {
|
||||
if ws := val.(*userSetup).workspace; ws != "" {
|
||||
baseWs = ws
|
||||
}
|
||||
}
|
||||
promptWorkspace = tools.ResolveWorkspace(baseWs,
|
||||
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve context files once — also detect BOOTSTRAP.md presence.
|
||||
|
||||
@@ -24,8 +24,28 @@ import (
|
||||
// Bootstrap typically completes in 2-3 conversation turns.
|
||||
const bootstrapAutoCleanupTurns = 3
|
||||
|
||||
// EnsureUserFilesFunc seeds per-user context files on first chat.
|
||||
// Returns the effective workspace path (from user_agent_profiles) for caching.
|
||||
// userSetup tracks per-user initialization state within a Loop instance.
|
||||
// Consolidates workspace resolution and context file seeding into one struct
|
||||
// to prevent desync between the two concerns.
|
||||
type userSetup struct {
|
||||
workspace string // effective workspace from user_agent_profiles (expanded, absolute)
|
||||
seeded bool // whether SeedUserFiles has been called this instance
|
||||
}
|
||||
|
||||
// EnsureUserProfileFunc creates/resolves a user's profile and workspace.
|
||||
// Returns the effective workspace path from user_agent_profiles.
|
||||
// Does NOT seed context files — that's SeedUserFilesFunc's responsibility.
|
||||
type EnsureUserProfileFunc func(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (effectiveWorkspace string, isNew bool, err error)
|
||||
|
||||
// SeedUserFilesFunc seeds per-user context files (BOOTSTRAP.md, USER.md, etc.).
|
||||
// Called once per user per Loop instance, independent of workspace.
|
||||
// isNew indicates whether the profile was just created (seed all) or already existed
|
||||
// (only seed if user has zero files — avoids re-seeding after BOOTSTRAP.md cleanup).
|
||||
type SeedUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string, isNew bool) error
|
||||
|
||||
// EnsureUserFilesFunc is the legacy combined callback (profile + seed + workspace).
|
||||
// Deprecated: use EnsureUserProfileFunc + SeedUserFilesFunc separately.
|
||||
// Kept for backward compatibility with existing callers during migration.
|
||||
type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error)
|
||||
|
||||
// ContextFileLoaderFunc loads context files dynamically per-request.
|
||||
@@ -75,12 +95,13 @@ type Loop struct {
|
||||
hasMemory bool
|
||||
contextFiles []bootstrap.ContextFile
|
||||
|
||||
// Per-user file seeding + dynamic context loading
|
||||
ensureUserFiles EnsureUserFilesFunc
|
||||
// Per-user profile + file seeding + dynamic context loading
|
||||
ensureUserProfile EnsureUserProfileFunc // create/resolve user profile + workspace
|
||||
seedUserFiles SeedUserFilesFunc // seed context files (BOOTSTRAP.md, USER.md)
|
||||
ensureUserFiles EnsureUserFilesFunc // legacy combined callback (fallback)
|
||||
contextFileLoader ContextFileLoaderFunc
|
||||
bootstrapCleanup BootstrapCleanupFunc
|
||||
userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles)
|
||||
userFilesSeeded sync.Map // userID → bool (whether SeedUserFiles has been called this instance)
|
||||
userSetups sync.Map // userID → *userSetup (workspace + seeding state, per Loop instance)
|
||||
|
||||
// Compaction config (memory flush settings)
|
||||
compactionCfg *config.CompactionConfig
|
||||
@@ -223,8 +244,10 @@ type LoopConfig struct {
|
||||
TenantID uuid.UUID // agent's owning tenant — injected into execution context
|
||||
AgentType string // "open" or "predefined"
|
||||
|
||||
// Per-user file seeding + dynamic context loading
|
||||
EnsureUserFiles EnsureUserFilesFunc
|
||||
// Per-user profile + file seeding + dynamic context loading
|
||||
EnsureUserProfile EnsureUserProfileFunc // preferred: separate profile + workspace
|
||||
SeedUserFiles SeedUserFilesFunc // preferred: separate context file seeding
|
||||
EnsureUserFiles EnsureUserFilesFunc // legacy: combined (used when above are nil)
|
||||
ContextFileLoader ContextFileLoaderFunc
|
||||
BootstrapCleanup BootstrapCleanupFunc
|
||||
|
||||
@@ -337,6 +360,8 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
skillAllowList: cfg.SkillAllowList,
|
||||
hasMemory: cfg.HasMemory,
|
||||
contextFiles: cfg.ContextFiles,
|
||||
ensureUserProfile: cfg.EnsureUserProfile,
|
||||
seedUserFiles: cfg.SeedUserFiles,
|
||||
ensureUserFiles: cfg.EnsureUserFiles,
|
||||
contextFileLoader: cfg.ContextFileLoader,
|
||||
bootstrapCleanup: cfg.BootstrapCleanup,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
@@ -58,10 +61,73 @@ func (l *Loop) shouldShareKnowledgeGraph() bool {
|
||||
return l.workspaceSharing != nil && l.workspaceSharing.ShareKnowledgeGraph
|
||||
}
|
||||
|
||||
// InvalidateUserWorkspace clears the cached workspace for a user,
|
||||
// forcing the next request to re-read from user_agent_profiles.
|
||||
// getOrCreateUserSetup returns the cached userSetup for a user, creating it on first call.
|
||||
// On first call: seeds context files (non-team) and resolves workspace from user profile.
|
||||
// On subsequent calls: returns cached setup immediately (no DB calls).
|
||||
func (l *Loop) getOrCreateUserSetup(ctx context.Context, userID, channel string, isTeamSession bool) *userSetup {
|
||||
if userID == "" {
|
||||
return &userSetup{workspace: l.workspace}
|
||||
}
|
||||
|
||||
// Fast path: already initialized
|
||||
if val, ok := l.userSetups.Load(userID); ok {
|
||||
return val.(*userSetup)
|
||||
}
|
||||
|
||||
// Slow path: first request for this user in this Loop instance.
|
||||
setup := &userSetup{}
|
||||
|
||||
if !isTeamSession {
|
||||
if l.ensureUserProfile != nil && l.seedUserFiles != nil {
|
||||
// Preferred path: separate callbacks for profile + seed.
|
||||
// Step 1: Create/resolve profile → get isNew + workspace
|
||||
ws, isNew, err := l.ensureUserProfile(ctx, l.agentUUID, userID, l.workspace, channel)
|
||||
if err != nil {
|
||||
slog.Warn("failed to ensure user profile", "error", err)
|
||||
} else if ws != "" {
|
||||
setup.workspace = expandWorkspace(ws)
|
||||
}
|
||||
// Step 2: Seed context files (must run before buildMessages reads them).
|
||||
// Passes isNew so SeedUserFiles knows whether to skip existing files.
|
||||
if err := l.seedUserFiles(ctx, l.agentUUID, userID, l.agentType, isNew); err != nil {
|
||||
slog.Warn("failed to seed user context files", "error", err)
|
||||
}
|
||||
setup.seeded = true
|
||||
} else if l.ensureUserFiles != nil {
|
||||
// Legacy fallback: combined callback handles both profile + seed
|
||||
ws, err := l.ensureUserFiles(ctx, l.agentUUID, userID, l.agentType, l.workspace, channel)
|
||||
if err != nil {
|
||||
slog.Warn("failed to ensure user context files", "error", err)
|
||||
} else if ws != "" {
|
||||
setup.workspace = expandWorkspace(ws)
|
||||
}
|
||||
setup.seeded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use agent's default workspace if profile didn't provide one
|
||||
if setup.workspace == "" && l.workspace != "" {
|
||||
setup.workspace = expandWorkspace(l.workspace)
|
||||
}
|
||||
|
||||
// Store atomically — if another goroutine raced, use their result
|
||||
actual, _ := l.userSetups.LoadOrStore(userID, setup)
|
||||
return actual.(*userSetup)
|
||||
}
|
||||
|
||||
// expandWorkspace expands ~ and converts to absolute path.
|
||||
func expandWorkspace(ws string) string {
|
||||
ws = config.ExpandHome(ws)
|
||||
if !filepath.IsAbs(ws) {
|
||||
ws, _ = filepath.Abs(ws)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// InvalidateUserWorkspace clears the cached setup for a user,
|
||||
// forcing the next request to re-resolve workspace and re-seed if needed.
|
||||
func (l *Loop) InvalidateUserWorkspace(userID string) {
|
||||
l.userWorkspaces.Delete(userID)
|
||||
l.userSetups.Delete(userID)
|
||||
}
|
||||
|
||||
// Provider returns the LLM provider for this agent loop.
|
||||
|
||||
@@ -35,8 +35,9 @@ type ResolverDeps struct {
|
||||
OnEvent func(AgentEvent)
|
||||
TraceCollector *tracing.Collector
|
||||
|
||||
// Per-user file seeding + dynamic context loading
|
||||
EnsureUserFiles EnsureUserFilesFunc
|
||||
// Per-user profile + file seeding + dynamic context loading
|
||||
EnsureUserProfile EnsureUserProfileFunc
|
||||
SeedUserFiles SeedUserFilesFunc
|
||||
ContextFileLoader ContextFileLoaderFunc
|
||||
BootstrapCleanup BootstrapCleanupFunc
|
||||
|
||||
@@ -365,7 +366,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
SkillAllowList: skillAllowList,
|
||||
HasMemory: hasMemory,
|
||||
ContextFiles: contextFiles,
|
||||
EnsureUserFiles: deps.EnsureUserFiles,
|
||||
EnsureUserProfile: deps.EnsureUserProfile,
|
||||
SeedUserFiles: deps.SeedUserFiles,
|
||||
ContextFileLoader: deps.ContextFileLoader,
|
||||
BootstrapCleanup: deps.BootstrapCleanup,
|
||||
OnEvent: deps.OnEvent,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// TestBuildSystemPrompt_BootstrapStates verifies the 4 bootstrap states
|
||||
// produce the correct system prompt sections.
|
||||
func TestBuildSystemPrompt_BootstrapStates(t *testing.T) {
|
||||
blankUserMD := "# USER.md\n\n- **Name:**\n- **Language:**\n- **Timezone:**\n"
|
||||
populatedUserMD := "# USER.md\n\n- **Name:** Alice\n- **Language:** English\n- **Timezone:** UTC+7\n"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg SystemPromptConfig
|
||||
wantIn string // substring that MUST appear
|
||||
wantNotIn string // substring that MUST NOT appear (empty = skip check)
|
||||
}{
|
||||
{
|
||||
name: "open agent with BOOTSTRAP.md → FIRST RUN slim mode",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: true,
|
||||
AgentType: store.AgentTypeOpen,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
|
||||
{Path: bootstrap.UserFile, Content: blankUserMD},
|
||||
},
|
||||
ToolNames: []string{"write_file", "Write"},
|
||||
},
|
||||
wantIn: "## FIRST RUN",
|
||||
wantNotIn: "USER PROFILE INCOMPLETE",
|
||||
},
|
||||
{
|
||||
name: "predefined agent with BOOTSTRAP.md → FIRST RUN full capabilities",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: false,
|
||||
AgentType: store.AgentTypePredefined,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
|
||||
{Path: bootstrap.UserFile, Content: blankUserMD},
|
||||
},
|
||||
ToolNames: []string{"write_file", "Write", "skill_search"},
|
||||
},
|
||||
wantIn: "## FIRST RUN",
|
||||
wantNotIn: "USER PROFILE INCOMPLETE",
|
||||
},
|
||||
{
|
||||
name: "no BOOTSTRAP.md + blank USER.md → USER PROFILE INCOMPLETE",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: false,
|
||||
AgentType: store.AgentTypePredefined,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.UserFile, Content: blankUserMD},
|
||||
},
|
||||
ToolNames: []string{"write_file"},
|
||||
},
|
||||
wantIn: "## USER PROFILE INCOMPLETE",
|
||||
wantNotIn: "FIRST RUN",
|
||||
},
|
||||
{
|
||||
name: "no BOOTSTRAP.md + populated USER.md → no nudge at all",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: false,
|
||||
AgentType: store.AgentTypePredefined,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.UserFile, Content: populatedUserMD},
|
||||
},
|
||||
ToolNames: []string{"write_file"},
|
||||
},
|
||||
wantNotIn: "FIRST RUN",
|
||||
},
|
||||
{
|
||||
name: "open agent slim mode has write_file note",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: true,
|
||||
AgentType: store.AgentTypeOpen,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
|
||||
},
|
||||
ToolNames: []string{"write_file"},
|
||||
},
|
||||
wantIn: "only have write_file available",
|
||||
},
|
||||
{
|
||||
name: "predefined agent first run has write_file instruction",
|
||||
cfg: SystemPromptConfig{
|
||||
IsBootstrap: false,
|
||||
AgentType: store.AgentTypePredefined,
|
||||
ContextFiles: []bootstrap.ContextFile{
|
||||
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
|
||||
},
|
||||
ToolNames: []string{"write_file", "web_search"},
|
||||
},
|
||||
wantIn: "MUST ALSO call write_file",
|
||||
wantNotIn: "only have write_file available",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prompt := BuildSystemPrompt(tt.cfg)
|
||||
|
||||
if tt.wantIn != "" && !strings.Contains(prompt, tt.wantIn) {
|
||||
t.Errorf("expected %q in system prompt, got:\n%s", tt.wantIn, prompt[:min(len(prompt), 500)])
|
||||
}
|
||||
if tt.wantNotIn != "" && strings.Contains(prompt, tt.wantNotIn) {
|
||||
t.Errorf("unexpected %q in system prompt", tt.wantNotIn)
|
||||
}
|
||||
|
||||
// Always verify: populated USER.md must never trigger INCOMPLETE
|
||||
if tt.name == "no BOOTSTRAP.md + populated USER.md → no nudge at all" {
|
||||
if strings.Contains(prompt, "USER PROFILE INCOMPLETE") {
|
||||
t.Error("populated USER.md should not trigger USER PROFILE INCOMPLETE")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSystemPrompt_NoBootstrapNoUser verifies that when there are no
|
||||
// bootstrap-related files at all, no nudge sections appear.
|
||||
func TestBuildSystemPrompt_NoBootstrapNoUser(t *testing.T) {
|
||||
prompt := BuildSystemPrompt(SystemPromptConfig{
|
||||
AgentType: store.AgentTypePredefined,
|
||||
ToolNames: []string{"write_file"},
|
||||
})
|
||||
|
||||
if strings.Contains(prompt, "FIRST RUN") {
|
||||
t.Error("unexpected FIRST RUN section with no context files")
|
||||
}
|
||||
if strings.Contains(prompt, "USER PROFILE INCOMPLETE") {
|
||||
t.Error("unexpected USER PROFILE INCOMPLETE section with no context files")
|
||||
}
|
||||
}
|
||||
@@ -99,17 +99,23 @@ var userSeedFilesPredefined = []string{
|
||||
}
|
||||
|
||||
// SeedUserFiles seeds embedded templates into user_context_files for a new user.
|
||||
// For "open" agents: all 7 files (including BOOTSTRAP.md).
|
||||
// For "open" agents: all 5 files (including BOOTSTRAP.md).
|
||||
// For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template).
|
||||
// Only writes files that don't already exist — safe to call multiple times.
|
||||
//
|
||||
// When skipIfAnyExist is true, returns immediately if the user already has ANY context
|
||||
// files (even with empty content slots). This prevents re-seeding BOOTSTRAP.md after
|
||||
// auto-cleanup (which DELETEs the row) on server restart — existing files like USER.md
|
||||
// indicate the user is not brand-new, so BOOTSTRAP.md should stay gone.
|
||||
// Use skipIfAnyExist=true for existing profiles, false for newly created profiles.
|
||||
//
|
||||
// For predefined agents seeding USER.md: if the agent already has a populated
|
||||
// USER.md in agent_context_files (e.g. written by the wizard or management dashboard),
|
||||
// that content is used as the per-user seed instead of the blank embedded template.
|
||||
// This ensures wizard-configured owner profiles are preserved on first chat.
|
||||
//
|
||||
// Returns the list of file names that were seeded.
|
||||
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) {
|
||||
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string, skipIfAnyExist bool) ([]string, error) {
|
||||
files := userSeedFilesOpen
|
||||
if agentType == store.AgentTypePredefined {
|
||||
files = userSeedFilesPredefined
|
||||
@@ -120,6 +126,13 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Early exit: user already has files → not a brand-new user.
|
||||
// Avoids re-seeding BOOTSTRAP.md after auto-cleanup on server restart.
|
||||
if skipIfAnyExist && len(existing) > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hasFile := make(map[string]bool, len(existing))
|
||||
for _, f := range existing {
|
||||
if f.Content != "" {
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD(t *testing.T) {
|
||||
// Simulate wizard writing USER.md at agent level via agents.files.set
|
||||
as.agentFiles[UserFile] = wizardContent
|
||||
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-alice", store.AgentTypePredefined)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-alice", store.AgentTypePredefined, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD
|
||||
agentID := uuid.New()
|
||||
// No agent-level USER.md — wizard did not write one
|
||||
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent(t
|
||||
// Also set wizard content at agent level
|
||||
as.agentFiles[UserFile] = "wizard content that should NOT override personal content"
|
||||
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-charlie", store.AgentTypePredefined)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-charlie", store.AgentTypePredefined, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate(t *testing.T) {
|
||||
agentID := uuid.New()
|
||||
// Open agents should never check agent_context_files for USER.md
|
||||
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-dave", store.AgentTypeOpen)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-dave", store.AgentTypeOpen, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
@@ -251,14 +251,14 @@ func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) {
|
||||
agentID := uuid.New()
|
||||
|
||||
// First call — seeds files
|
||||
SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined)
|
||||
SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined, false)
|
||||
|
||||
// Simulate what the first call wrote (move seededUserFiles → userFiles)
|
||||
maps.Copy(as.userFiles, as.seededUserFiles)
|
||||
as.seededUserFiles = make(map[string]string)
|
||||
|
||||
// Second call — must seed nothing (all files already exist)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined)
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined, false)
|
||||
if err != nil {
|
||||
t.Fatalf("second SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
@@ -269,3 +269,60 @@ func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) {
|
||||
t.Errorf("second call should not write any files, but wrote: %v", as.seededUserFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedUserFiles_SkipIfAnyExist verifies that skipIfAnyExist=true prevents
|
||||
// re-seeding BOOTSTRAP.md after auto-cleanup (which DELETEs the row).
|
||||
// This is the key scenario: server restart → ensureUserFiles called → existing profile
|
||||
// has USER.md but BOOTSTRAP.md was cleaned up → must NOT re-seed BOOTSTRAP.md.
|
||||
func TestSeedUserFiles_SkipIfAnyExist(t *testing.T) {
|
||||
as := newSeedStub()
|
||||
agentID := uuid.New()
|
||||
|
||||
// Step 1: Seed initial files (new user, skipIfAnyExist=false)
|
||||
SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, false)
|
||||
maps.Copy(as.userFiles, as.seededUserFiles)
|
||||
as.seededUserFiles = make(map[string]string)
|
||||
|
||||
// Step 2: Simulate auto-cleanup — BOOTSTRAP.md deleted, USER.md remains
|
||||
delete(as.userFiles, BootstrapFile)
|
||||
|
||||
// Step 3: skipIfAnyExist=true (existing profile) → must NOT re-seed
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles(skipIfAnyExist=true) returned error: %v", err)
|
||||
}
|
||||
if len(seeded) != 0 {
|
||||
t.Errorf("skipIfAnyExist=true should seed nothing when user has files, but seeded: %v", seeded)
|
||||
}
|
||||
|
||||
// Step 4: skipIfAnyExist=false (force) → SHOULD re-seed missing BOOTSTRAP.md
|
||||
seeded, err = SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles(skipIfAnyExist=false) returned error: %v", err)
|
||||
}
|
||||
foundBootstrap := false
|
||||
for _, f := range seeded {
|
||||
if f == BootstrapFile {
|
||||
foundBootstrap = true
|
||||
}
|
||||
}
|
||||
if !foundBootstrap {
|
||||
t.Errorf("skipIfAnyExist=false should re-seed missing BOOTSTRAP.md, but seeded: %v", seeded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedUserFiles_SkipIfAnyExist_EmptyUser verifies that skipIfAnyExist=true
|
||||
// still seeds when the user has NO files at all (EnsureUserProfile pre-creation case).
|
||||
func TestSeedUserFiles_SkipIfAnyExist_EmptyUser(t *testing.T) {
|
||||
as := newSeedStub()
|
||||
agentID := uuid.New()
|
||||
|
||||
// No existing user files → skipIfAnyExist=true should still seed
|
||||
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-ghost", store.AgentTypePredefined, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedUserFiles returned error: %v", err)
|
||||
}
|
||||
if len(seeded) == 0 {
|
||||
t.Error("skipIfAnyExist=true with zero existing files should seed, but seeded nothing")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user