fix(agents): sync IDENTITY.md Name field on agent rename (#582)

## Summary

- Agent rename now updates Name field in IDENTITY.md (agent-level + per-user copies for open agents) via both HTTP and WS paths
- Fixes lossy identity rebuild: previous logic reconstructed IDENTITY.md from only Name/Emoji/Avatar, silently dropping LLM-written fields (Creature, Purpose, Vibe, etc.)
- Uses targeted field replacement (bootstrap.UpdateIdentityField) preserving original formatting
- Adds ListUserContextFilesByName to AgentContextStore (PG + SQLite)
- Fixes LastIndex bug in UpdateIdentityField where values containing colons (e.g. Avatar URLs) would break field replacement
- Removes ~335 lines of dead config.json fallback code (agentStore is always set)
- Adds 9 unit tests for UpdateIdentityField covering plain/markdown formats, URLs, edge cases
This commit is contained in:
teexiii authored and GitHub committed 2026-03-30 20:56:49 +07:00
1 parent 0e2db21345
commit 88a5793030
14 files changed
+275 -318

No files matched your search

+46
View File
@@ -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")
}
@@ -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)
}
})
}
}
+3
View File
@@ -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
}
-32
View File
@@ -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
@@ -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
}
-32
View File
@@ -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{
-96
View File
@@ -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)
}
@@ -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")
}
+38 -76
View File
@@ -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{
+45
View File
@@ -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())
+3
View File
@@ -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.
+24
View File
@@ -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 {
@@ -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 {
@@ -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 }