package methods import ( "context" "encoding/json" "fmt" "log/slog" "os" "strings" "github.com/nextlevelbuilder/goclaw/internal/audio" "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" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // --- agents.update --- // Matching TS src/gateway/server-methods/agents.ts:288-346 func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) var params struct { AgentID string `json:"agentId"` Name string `json:"name"` Workspace string `json:"workspace"` Provider string `json:"provider"` Model string `json:"model"` Avatar string `json:"avatar"` Status string `json:"status"` Frontmatter string `json:"frontmatter"` ContextWindow *int `json:"context_window"` MaxToolIterations *int `json:"max_tool_iterations"` IsDefault *bool `json:"is_default"` BudgetCents *int `json:"budget_monthly_cents"` // Per-agent config overrides ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` MemoryConfig json.RawMessage `json:"memory_config,omitempty"` CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` ContextPruning json.RawMessage `json:"context_pruning,omitempty"` OtherConfig json.RawMessage `json:"other_config,omitempty"` // Promoted config fields Emoji *string `json:"emoji,omitempty"` AgentDescription *string `json:"agent_description,omitempty"` ThinkingLevel *string `json:"thinking_level,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` SelfEvolve *bool `json:"self_evolve,omitempty"` SkillEvolve *bool `json:"skill_evolve,omitempty"` SkillNudgeInterval *int `json:"skill_nudge_interval,omitempty"` ReasoningConfig json.RawMessage `json:"reasoning_config,omitempty"` WorkspaceSharing json.RawMessage `json:"workspace_sharing,omitempty"` ChatGPTOAuthRouting json.RawMessage `json:"chatgpt_oauth_routing,omitempty"` ShellDenyGroups json.RawMessage `json:"shell_deny_groups,omitempty"` KGDedupConfig json.RawMessage `json:"kg_dedup_config,omitempty"` } if req.Params != nil { json.Unmarshal(req.Params, ¶ms) } var rawParams map[string]json.RawMessage if req.Params != nil { _ = json.Unmarshal(req.Params, &rawParams) } if params.AgentID == "" { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId"))) return } if m.agentStore != nil { // --- DB-backed: update agent in store --- ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) return } updates := map[string]any{} if params.Name != "" { updates["display_name"] = params.Name } if params.Workspace != "" { ws := config.ExpandHome(params.Workspace) updates["workspace"] = ws os.MkdirAll(ws, 0755) } if params.Provider != "" { updates["provider"] = params.Provider } if params.Model != "" { updates["model"] = params.Model } if params.Status != "" { updates["status"] = params.Status } if params.Frontmatter != "" { updates["frontmatter"] = params.Frontmatter } if params.ContextWindow != nil { updates["context_window"] = *params.ContextWindow } if params.MaxToolIterations != nil { updates["max_tool_iterations"] = *params.MaxToolIterations } if params.IsDefault != nil { updates["is_default"] = *params.IsDefault } if rawBudget, ok := rawParams["budget_monthly_cents"]; ok && strings.TrimSpace(string(rawBudget)) == "null" { updates["budget_monthly_cents"] = nil } else if params.BudgetCents != nil { updates["budget_monthly_cents"] = *params.BudgetCents } // Per-agent JSONB config overrides if len(params.ToolsConfig) > 0 { updates["tools_config"] = []byte(params.ToolsConfig) } if len(params.SubagentsConfig) > 0 { updates["subagents_config"] = []byte(params.SubagentsConfig) } if len(params.SandboxConfig) > 0 { updates["sandbox_config"] = []byte(params.SandboxConfig) } if len(params.MemoryConfig) > 0 { updates["memory_config"] = []byte(params.MemoryConfig) } if len(params.CompactionConfig) > 0 { updates["compaction_config"] = []byte(params.CompactionConfig) } if len(params.ContextPruning) > 0 { updates["context_pruning"] = []byte(params.ContextPruning) } if len(params.OtherConfig) > 0 { // Validate v3 flag values (must be boolean) before persisting. var otherMap map[string]any if json.Unmarshal(params.OtherConfig, &otherMap) == nil { if err := store.ValidateV3Flags(otherMap); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return } // Finding #5: validate tts_params allow-list via shared audio validator // (Action D: single source of truth in internal/audio). if tp, ok := otherMap["tts_params"]; ok && tp != nil { if tpMap, ok := tp.(map[string]any); ok { if err := audio.ValidateAgentTTSParams(tpMap); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return } } } } updates["other_config"] = []byte(params.OtherConfig) } // Promoted config fields if params.Emoji != nil { updates["emoji"] = *params.Emoji } if params.AgentDescription != nil { updates["agent_description"] = *params.AgentDescription } if params.ThinkingLevel != nil { updates["thinking_level"] = *params.ThinkingLevel } if params.MaxTokens != nil { updates["max_tokens"] = *params.MaxTokens } if params.SelfEvolve != nil { updates["self_evolve"] = *params.SelfEvolve } if params.SkillEvolve != nil { updates["skill_evolve"] = *params.SkillEvolve } if params.SkillNudgeInterval != nil { v := max(*params.SkillNudgeInterval, // DB column is NOT NULL DEFAULT 0 0) updates["skill_nudge_interval"] = v } if len(params.ReasoningConfig) > 0 { updates["reasoning_config"] = []byte(params.ReasoningConfig) } if len(params.WorkspaceSharing) > 0 { updates["workspace_sharing"] = []byte(params.WorkspaceSharing) } if len(params.ChatGPTOAuthRouting) > 0 { updates["chatgpt_oauth_routing"] = []byte(params.ChatGPTOAuthRouting) } if len(params.ShellDenyGroups) > 0 { updates["shell_deny_groups"] = []byte(params.ShellDenyGroups) } if len(params.KGDedupConfig) > 0 { updates["kg_dedup_config"] = []byte(params.KGDedupConfig) } if len(updates) > 0 { if err := m.agentStore.Update(ctx, ag.ID, updates); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "agent", fmt.Sprintf("%v", err)))) return } } // Update identity in DB bootstrap — targeted field replacement to preserve all other fields. if params.Avatar != "" || params.Name != "" { // 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" { existingContent = f.Content break } } } // Apply targeted replacements, preserving all other fields (Creature, Purpose, Vibe, etc.). newContent := existingContent if params.Name != "" { newContent = bootstrap.UpdateIdentityField(newContent, "Name", params.Name) } if params.Avatar != "" { newContent = bootstrap.UpdateIdentityField(newContent, "Avatar", params.Avatar) } // Fallback: if content was empty (no IDENTITY.md yet), build minimal content. if strings.TrimSpace(newContent) == "" { newContent = buildIdentityContent(params.Name, "", params.Avatar) } 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) } } // Post-Phase-2 canonicalization: router cache entries are always keyed // as `tenantID:agent_key`. The previous belt-and-suspenders UUID-based // invalidation was dead code — exact-segment match never matches a // UUID as the final segment of a canonical cache key. m.agents.InvalidateAgent(params.AgentID) } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, "agentId": params.AgentID, })) emitAudit(m.eventBus, client, "agent.updated", "agent", params.AgentID) }