mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
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:
1 parent
343ad0c2b1
commit
23c43259c9
6 files changed
+92
-40
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user