diff --git a/internal/bootstrap/identity_update.go b/internal/bootstrap/identity_update.go new file mode 100644 index 00000000..f143fb57 --- /dev/null +++ b/internal/bootstrap/identity_update.go @@ -0,0 +1,46 @@ +package bootstrap + +import "strings" + +// UpdateIdentityField replaces a single field line in IDENTITY.md content without +// touching any other fields. Handles both plain format ("Name: value") and the +// LLM-generated markdown format ("- **Name:** value") written during onboarding. +// If no matching line is found, one is inserted after the first heading. +func UpdateIdentityField(content, fieldName, newValue string) string { + if newValue == "" { + return content + } + marker := fieldName + ":" + lines := strings.Split(content, "\n") + for i, line := range lines { + // Strip leading list/bold markdown markers (-, *, #, spaces) to normalize + // both "Name: value" and "- **Name:** value" to the same comparison base. + stripped := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*# \t")) + if strings.HasPrefix(stripped, marker) { + // Find the marker colon in the original line by locating the marker text, + // NOT LastIndex — values may contain colons (e.g. Avatar URLs). + markerIdx := strings.Index(stripped, ":") + // Map back to original line: find where stripped starts in the original. + prefixLen := strings.Index(line, stripped[:markerIdx+1]) + colonIdx := prefixLen + markerIdx + // Include any "**" immediately after the colon so ":**" stays intact. + afterColon := line[colonIdx+1:] + starLen := len(afterColon) - len(strings.TrimLeft(afterColon, "*")) + lines[i] = line[:colonIdx+1+starLen] + " " + newValue + return strings.Join(lines, "\n") + } + } + // No existing line for this field — insert after first heading or at top. + insertAt := 0 + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + insertAt = i + 1 + break + } + } + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:insertAt]...) + result = append(result, fieldName+": "+newValue) + result = append(result, lines[insertAt:]...) + return strings.Join(result, "\n") +} diff --git a/internal/bootstrap/identity_update_test.go b/internal/bootstrap/identity_update_test.go new file mode 100644 index 00000000..594c539a --- /dev/null +++ b/internal/bootstrap/identity_update_test.go @@ -0,0 +1,86 @@ +package bootstrap + +import "testing" + +func TestUpdateIdentityField(t *testing.T) { + tests := []struct { + name string + content string + fieldName string + newValue string + want string + }{ + { + name: "plain format", + content: "# Identity\nName: OldName\nEmoji: 🤖", + fieldName: "Name", + newValue: "NewName", + want: "# Identity\nName: NewName\nEmoji: 🤖", + }, + { + name: "LLM markdown format", + content: "# Identity\n- **Name:** OldName\n- **Creature:** Fox", + fieldName: "Name", + newValue: "NewName", + want: "# Identity\n- **Name:** NewName\n- **Creature:** Fox", + }, + { + name: "field not found inserts after heading", + content: "# Identity\nEmoji: 🤖", + fieldName: "Name", + newValue: "NewName", + want: "# Identity\nName: NewName\nEmoji: 🤖", + }, + { + name: "empty content inserts at top", + content: "", + fieldName: "Name", + newValue: "NewName", + want: "Name: NewName\n", + }, + { + name: "empty newValue returns unchanged", + content: "# Identity\nName: OldName", + fieldName: "Name", + newValue: "", + want: "# Identity\nName: OldName", + }, + { + name: "value with colon (URL) preserved on other fields", + content: "# Identity\nName: Bot\nAvatar: https://example.com/avatar.png", + fieldName: "Name", + newValue: "NewBot", + want: "# Identity\nName: NewBot\nAvatar: https://example.com/avatar.png", + }, + { + name: "update avatar URL field correctly", + content: "# Identity\nName: Bot\nAvatar: https://old.com/img.png", + fieldName: "Avatar", + newValue: "https://new.com/img.png", + want: "# Identity\nName: Bot\nAvatar: https://new.com/img.png", + }, + { + name: "LLM markdown avatar with URL", + content: "# Identity\n- **Name:** Bot\n- **Avatar:** https://old.com/img.png", + fieldName: "Avatar", + newValue: "https://new.com/img.png", + want: "# Identity\n- **Name:** Bot\n- **Avatar:** https://new.com/img.png", + }, + { + name: "preserves other LLM fields", + content: "# Identity\n- **Name:** Bot\n- **Creature:** Fox\n- **Purpose:** Help users\n- **Vibe:** Friendly", + fieldName: "Name", + newValue: "NewBot", + want: "# Identity\n- **Name:** NewBot\n- **Creature:** Fox\n- **Purpose:** Help users\n- **Vibe:** Friendly", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UpdateIdentityField(tt.content, tt.fieldName, tt.newValue) + if got != tt.want { + t.Errorf("UpdateIdentityField() =\n%q\nwant:\n%q", got, tt.want) + } + }) + } +} diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index b9d00a7b..b9cd817f 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -51,6 +51,9 @@ func (s *seedStubStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, na s.seededUserFiles[name] = content return nil } +func (s *seedStubStore) ListUserContextFilesByName(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { + return nil, nil +} func (s *seedStubStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go index a8d8965c..c7fbd21d 100644 --- a/internal/gateway/methods/agents_create.go +++ b/internal/gateway/methods/agents_create.go @@ -156,38 +156,6 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client // Invalidate router cache so resolver re-loads from DB m.agents.InvalidateAgent(agentID) - } else { - // --- Fallback: config.json + filesystem --- - if _, ok := m.cfg.Agents.List[agentID]; ok { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAlreadyExists, "agent", agentID))) - return - } - - spec := config.AgentSpec{ - DisplayName: params.Name, - Workspace: ws, - } - if params.Emoji != "" || params.Avatar != "" { - spec.Identity = &config.IdentityConfig{ - Emoji: params.Emoji, - } - } - - if m.cfg.Agents.List == nil { - m.cfg.Agents.List = make(map[string]config.AgentSpec) - } - m.cfg.Agents.List[agentID] = spec - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error()))) - return - } - - // Append identity metadata to IDENTITY.md - if params.Name != "" || params.Emoji != "" || params.Avatar != "" { - identityPath := filepath.Join(ws, "IDENTITY.md") - appendIdentityFields(identityPath, params.Name, params.Emoji, params.Avatar) - } } // Both modes: create workspace dir + seed filesystem backup diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 5eb915aa..c72aaa93 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -77,6 +77,9 @@ func (s *createCaptureStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, func (s *createCaptureStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, _, _ string) error { return nil } +func (s *createCaptureStore) ListUserContextFilesByName(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { + return nil, nil +} func (s *createCaptureStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go index e83e39e9..cecac153 100644 --- a/internal/gateway/methods/agents_delete.go +++ b/internal/gateway/methods/agents_delete.go @@ -7,7 +7,6 @@ import ( "os" "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -71,37 +70,6 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client if params.DeleteFiles && ag.Workspace != "" { os.RemoveAll(ag.Workspace) } - } else { - // --- Fallback: config.json --- - spec, ok := m.cfg.Agents.List[params.AgentID] - if !ok { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) - return - } - - delete(m.cfg.Agents.List, params.AgentID) - - var kept []config.AgentBinding - for _, b := range m.cfg.Bindings { - if b.AgentID == params.AgentID { - removedBindings++ - } else { - kept = append(kept, b) - } - } - m.cfg.Bindings = kept - - m.agents.Remove(params.AgentID) - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error()))) - return - } - - if params.DeleteFiles && spec.Workspace != "" { - ws := config.ExpandHome(spec.Workspace) - os.RemoveAll(ws) - } } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go index 42c390e8..01b93215 100644 --- a/internal/gateway/methods/agents_files.go +++ b/internal/gateway/methods/agents_files.go @@ -4,12 +4,9 @@ import ( "context" "encoding/json" "log/slog" - "os" - "path/filepath" "slices" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -80,36 +77,6 @@ func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Cli })) return } - - // --- Fallback: filesystem --- - ws := m.resolveWorkspace(params.AgentID) - files := make([]map[string]any, 0, len(allowedAgentFiles)) - - for _, name := range allowedAgentFiles { - p := filepath.Join(ws, name) - info, err := os.Stat(p) - if err != nil { - files = append(files, map[string]any{ - "name": name, - "path": p, - "missing": true, - }) - } else { - files = append(files, map[string]any{ - "name": name, - "path": p, - "missing": false, - "size": info.Size(), - "updatedAtMs": info.ModTime().UnixMilli(), - }) - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "agentId": params.AgentID, - "workspace": ws, - "files": files, - })) } // --- agents.files.get --- @@ -176,38 +143,6 @@ func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Clie })) return } - - // --- Fallback: filesystem --- - ws := m.resolveWorkspace(params.AgentID) - p := filepath.Join(ws, params.Name) - - info, err := os.Stat(p) - if err != nil { - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "agentId": params.AgentID, - "workspace": ws, - "file": map[string]any{ - "name": params.Name, - "path": p, - "missing": true, - }, - })) - return - } - - content, _ := os.ReadFile(p) - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "agentId": params.AgentID, - "workspace": ws, - "file": map[string]any{ - "name": params.Name, - "path": p, - "missing": false, - "size": info.Size(), - "updatedAtMs": info.ModTime().UnixMilli(), - "content": string(content), - }, - })) } // --- agents.files.set --- @@ -280,41 +215,10 @@ func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Clie })) return } - - // --- Fallback: filesystem --- - ws := m.resolveWorkspace(params.AgentID) - os.MkdirAll(ws, 0755) - p := filepath.Join(ws, params.Name) - - if err := os.WriteFile(p, []byte(params.Content), 0644); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error()))) - return - } - - info, _ := os.Stat(p) - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "agentId": params.AgentID, - "workspace": ws, - "file": map[string]any{ - "name": params.Name, - "path": p, - "missing": false, - "size": info.Size(), - "updatedAtMs": info.ModTime().UnixMilli(), - "content": params.Content, - }, - })) } // --- Helpers --- -func (m *AgentsMethods) resolveWorkspace(agentID string) string { - if spec, ok := m.cfg.Agents.List[agentID]; ok && spec.Workspace != "" { - return config.ExpandHome(spec.Workspace) - } - return config.ExpandHome(m.cfg.Agents.Defaults.Workspace) -} - func isAllowedFile(name string) bool { return slices.Contains(allowedAgentFiles, name) } diff --git a/internal/gateway/methods/agents_identity.go b/internal/gateway/methods/agents_identity.go index 403e6899..1b7ed51a 100644 --- a/internal/gateway/methods/agents_identity.go +++ b/internal/gateway/methods/agents_identity.go @@ -1,11 +1,8 @@ package methods import ( - "bufio" "context" "encoding/json" - "os" - "path/filepath" "strings" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -68,35 +65,6 @@ func (m *AgentsMethods) handleIdentityGet(ctx context.Context, client *gateway.C } } } - } else { - // --- Fallback: config + filesystem --- - result["name"] = m.cfg.ResolveDisplayName(params.AgentID) - - if spec, ok := m.cfg.Agents.List[params.AgentID]; ok && spec.Identity != nil { - if spec.Identity.Emoji != "" { - result["emoji"] = spec.Identity.Emoji - } - if spec.Identity.Name != "" { - result["name"] = spec.Identity.Name - } - } - - ws := m.resolveWorkspace(params.AgentID) - identityPath := filepath.Join(ws, "IDENTITY.md") - if identity := parseIdentityFile(identityPath); identity != nil { - if identity["Name"] != "" { - result["name"] = identity["Name"] - } - if identity["Emoji"] != "" { - result["emoji"] = identity["Emoji"] - } - if identity["Avatar"] != "" { - result["avatar"] = identity["Avatar"] - } - if identity["Description"] != "" { - result["description"] = identity["Description"] - } - } } client.SendResponse(protocol.NewOKResponse(req.ID, result)) @@ -121,32 +89,6 @@ func parseIdentityContent(content string) map[string]string { return result } -// parseIdentityFile reads IDENTITY.md and extracts Key: Value fields. -func parseIdentityFile(path string) map[string]string { - f, err := os.Open(path) - if err != nil { - return nil - } - defer f.Close() - - result := make(map[string]string) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "#") || line == "" { - continue - } - if idx := strings.Index(line, ":"); idx > 0 { - key := strings.TrimSpace(line[:idx]) - val := strings.TrimSpace(line[idx+1:]) - if val != "" { - result[key] = val - } - } - } - return result -} - // buildIdentityContent creates the content for IDENTITY.md from fields. func buildIdentityContent(name, emoji, avatar string) string { var lines []string @@ -162,27 +104,3 @@ func buildIdentityContent(name, emoji, avatar string) string { } return strings.Join(lines, "\n") + "\n" } - -// appendIdentityFields appends Name/Emoji/Avatar to IDENTITY.md. -func appendIdentityFields(path string, name, emoji, avatar string) { - var lines []string - if name != "" { - lines = append(lines, "Name: "+name) - } - if emoji != "" { - lines = append(lines, "Emoji: "+emoji) - } - if avatar != "" { - lines = append(lines, "Avatar: "+avatar) - } - if len(lines) == 0 { - return - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - f.WriteString("\n" + strings.Join(lines, "\n") + "\n") -} diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go index 62f88917..a308533f 100644 --- a/internal/gateway/methods/agents_update.go +++ b/internal/gateway/methods/agents_update.go @@ -6,8 +6,9 @@ import ( "fmt" "log/slog" "os" - "path/filepath" + "strings" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -122,35 +123,53 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client } } - // Update identity in DB bootstrap — preserve existing fields not being changed. + // Update identity in DB bootstrap — targeted field replacement to preserve all other fields. if params.Avatar != "" || params.Name != "" { - // Read existing identity to preserve emoji and other fields. - existingEmoji, existingAvatar, existingName := "", "", "" + // Read existing agent-level IDENTITY.md content. + existingContent := "" if dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID); err == nil { for _, f := range dbFiles { if f.FileName == "IDENTITY.md" { - if identity := parseIdentityContent(f.Content); identity != nil { - existingEmoji = identity["Emoji"] - existingAvatar = identity["Avatar"] - existingName = identity["Name"] - } + existingContent = f.Content break } } } - name := params.Name - if name == "" { - name = existingName + + // Apply targeted replacements, preserving all other fields (Creature, Purpose, Vibe, etc.). + newContent := existingContent + if params.Name != "" { + newContent = bootstrap.UpdateIdentityField(newContent, "Name", params.Name) } - avatar := params.Avatar - if avatar == "" { - avatar = existingAvatar + if params.Avatar != "" { + newContent = bootstrap.UpdateIdentityField(newContent, "Avatar", params.Avatar) } - content := buildIdentityContent(name, existingEmoji, avatar) - if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, "IDENTITY.md", content); err != nil { - slog.Warn("failed to update IDENTITY.md", "agent", params.AgentID, "error", err) + // Fallback: if content was empty (no IDENTITY.md yet), build minimal content. + if strings.TrimSpace(newContent) == "" { + newContent = buildIdentityContent(params.Name, "", params.Avatar) } - // Invalidate interceptor cache so updated IDENTITY.md is served immediately + + if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, "IDENTITY.md", newContent); err != nil { + slog.Warn("failed to update agent IDENTITY.md", "agent", params.AgentID, "error", err) + } + + // For open agents: also update Name in all per-user IDENTITY.md copies. + // Per-user files take precedence in LoadContextFiles, so they must be updated too. + if params.Name != "" && ag.AgentType == store.AgentTypeOpen { + if userFiles, err := m.agentStore.ListUserContextFilesByName(ctx, ag.ID, "IDENTITY.md"); err == nil { + for _, uf := range userFiles { + updated := bootstrap.UpdateIdentityField(uf.Content, "Name", params.Name) + if updated == uf.Content { + continue // no change needed + } + if err := m.agentStore.SetUserContextFile(ctx, ag.ID, uf.UserID, "IDENTITY.md", updated); err != nil { + slog.Warn("failed to update user IDENTITY.md on rename", "agent", params.AgentID, "user", uf.UserID, "error", err) + } + } + } + } + + // Invalidate interceptor cache so updated IDENTITY.md is served immediately. if m.interceptor != nil { m.interceptor.InvalidateAgent(ag.ID) } @@ -160,63 +179,6 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client // Also invalidate by UUID — heartbeat/cron sessions cached under UUID key // before the agentKey fix may still be in the router cache. m.agents.InvalidateAgent(ag.ID.String()) - } else { - // --- Fallback: config.json --- - spec, ok := m.cfg.Agents.List[params.AgentID] - if !ok { - if params.AgentID != "default" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) - return - } - } - - if params.Name != "" { - spec.DisplayName = params.Name - } - if params.Workspace != "" { - spec.Workspace = config.ExpandHome(params.Workspace) - os.MkdirAll(spec.Workspace, 0755) - } - if params.Provider != "" { - spec.Provider = params.Provider - } - if params.Model != "" { - spec.Model = params.Model - } - if params.ContextWindow != nil { - spec.ContextWindow = *params.ContextWindow - } - if params.MaxToolIterations != nil { - spec.MaxToolIterations = *params.MaxToolIterations - } - - if params.AgentID == "default" { - if params.Provider != "" { - m.cfg.Agents.Defaults.Provider = params.Provider - } - if params.Model != "" { - m.cfg.Agents.Defaults.Model = params.Model - } - if params.Workspace != "" { - m.cfg.Agents.Defaults.Workspace = params.Workspace - } - } else { - m.cfg.Agents.List[params.AgentID] = spec - } - - if params.Avatar != "" { - ws := spec.Workspace - if ws == "" { - ws = config.ExpandHome(m.cfg.Agents.Defaults.Workspace) - } - identityPath := filepath.Join(ws, "IDENTITY.md") - appendIdentityFields(identityPath, "", "", params.Avatar) - } - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error()))) - return - } } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ diff --git a/internal/http/agents.go b/internal/http/agents.go index 40fcf7c2..f90780f3 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -1,6 +1,7 @@ package http import ( + "context" "database/sql" "encoding/json" "fmt" @@ -354,6 +355,11 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { return } + // Sync display_name change into IDENTITY.md so the agent self-reports the new name. + if newName, ok := allowed["display_name"].(string); ok && newName != "" { + h.syncIdentityName(r.Context(), ag, newName) + } + // Invalidate caches: agent Loop + bootstrap files h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey) h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String()) @@ -375,6 +381,45 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } +// syncIdentityName updates the Name: field in the agent's IDENTITY.md (agent-level and +// all per-user copies for open agents) so the agent self-reports the new display name. +// Errors are logged but do not fail the rename request. +func (h *AgentsHandler) syncIdentityName(ctx context.Context, ag *store.AgentData, newName string) { + // Read existing agent-level IDENTITY.md. + existingContent := "" + if dbFiles, err := h.agents.GetAgentContextFiles(ctx, ag.ID); err == nil { + for _, f := range dbFiles { + if f.FileName == bootstrap.IdentityFile { + existingContent = f.Content + break + } + } + } + + newContent := bootstrap.UpdateIdentityField(existingContent, "Name", newName) + if newContent == "" { + newContent = "# Identity\nName: " + newName + "\n" + } + if err := h.agents.SetAgentContextFile(ctx, ag.ID, bootstrap.IdentityFile, newContent); err != nil { + slog.Warn("agents.update: failed to sync IDENTITY.md name", "agent", ag.AgentKey, "error", err) + } + + // For open agents, also update per-user IDENTITY.md copies. + if ag.AgentType == store.AgentTypeOpen { + if userFiles, err := h.agents.ListUserContextFilesByName(ctx, ag.ID, bootstrap.IdentityFile); err == nil { + for _, uf := range userFiles { + updated := bootstrap.UpdateIdentityField(uf.Content, "Name", newName) + if updated == uf.Content { + continue + } + if err := h.agents.SetUserContextFile(ctx, ag.ID, uf.UserID, bootstrap.IdentityFile, updated); err != nil { + slog.Warn("agents.update: failed to sync user IDENTITY.md name", "agent", ag.AgentKey, "user", uf.UserID, "error", err) + } + } + } + } +} + func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { userID := store.UserIDFromContext(r.Context()) locale := store.LocaleFromContext(r.Context()) diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index ab26a22e..5e6ba71e 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -503,6 +503,9 @@ type AgentContextStore interface { SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error PropagateContextFile(ctx context.Context, agentID uuid.UUID, fileName string) (int, error) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]UserContextFileData, error) + // ListUserContextFilesByName returns all per-user copies of fileName across all users of agentID. + // Used for bulk targeted updates (e.g. updating Name: in IDENTITY.md on agent rename). + ListUserContextFilesByName(ctx context.Context, agentID uuid.UUID, fileName string) ([]UserContextFileData, error) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error // MigrateUserDataOnMerge moves per-user data from oldUserIDs to newUserID when contacts are merged. diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index 362a63c2..b1763fc7 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -115,6 +115,30 @@ func (s *PGAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID return err } +func (s *PGAgentStore) ListUserContextFilesByName(ctx context.Context, agentID uuid.UUID, fileName string) ([]store.UserContextFileData, error) { + tClause, tArgs, _, err := scopeClause(ctx, 3) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND file_name = $2"+tClause, + append([]any{agentID, fileName}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.UserContextFileData + for rows.Next() { + var d store.UserContextFileData + if err := rows.Scan(&d.AgentID, &d.UserID, &d.FileName, &d.Content); err != nil { + continue + } + result = append(result, d) + } + return result, rows.Err() +} + func (s *PGAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error { tClause, tArgs, _, err := scopeClause(ctx, 4) if err != nil { diff --git a/internal/store/sqlitestore/agents_context.go b/internal/store/sqlitestore/agents_context.go index 8fb42639..a3dc2418 100644 --- a/internal/store/sqlitestore/agents_context.go +++ b/internal/store/sqlitestore/agents_context.go @@ -125,6 +125,30 @@ func (s *SQLiteAgentStore) SetUserContextFile(ctx context.Context, agentID uuid. return err } +func (s *SQLiteAgentStore) ListUserContextFilesByName(ctx context.Context, agentID uuid.UUID, fileName string) ([]store.UserContextFileData, error) { + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND file_name = ?"+tClause, + append([]any{agentID, fileName}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.UserContextFileData + for rows.Next() { + var d store.UserContextFileData + if err := rows.Scan(&d.AgentID, &d.UserID, &d.FileName, &d.Content); err != nil { + continue + } + result = append(result, d) + } + return result, rows.Err() +} + func (s *SQLiteAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error { tClause, tArgs, err := scopeClause(ctx) if err != nil { diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 9a6a10ff..0c1bb65a 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -40,6 +40,9 @@ func (s *stubAgentStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, _ func (s *stubAgentStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } +func (s *stubAgentStore) ListUserContextFilesByName(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) { + return nil, nil +} // Remaining interface methods — not exercised in these tests. func (s *stubAgentStore) Create(_ context.Context, _ *store.AgentData) error { return nil }