mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
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:
1 parent
0e2db21345
commit
88a5793030
14 files changed
+275
-318
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user