diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f9f1390c..77d1de32 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -176,6 +176,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.agentType != "" { ctx = store.WithAgentType(ctx, l.agentType) } + // Inject self-evolve flag for predefined agents that can update SOUL.md + if l.selfEvolve { + ctx = store.WithSelfEvolve(ctx, true) + } // Inject original sender ID for group file writer permission checks if req.SenderID != "" { ctx = store.WithSenderID(ctx, req.SenderID) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 35d33624..287cf894 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -85,6 +85,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s SandboxEnabled: l.sandboxEnabled, SandboxContainerDir: l.sandboxContainerDir, SandboxWorkspaceAccess: l.sandboxWorkspaceAccess, + SelfEvolve: l.selfEvolve, }) messages = append(messages, providers.Message{ diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 53a3bd0e..0eca1bc4 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -98,6 +98,9 @@ type Loop struct { // Thinking level for extended thinking support thinkingLevel string + // Self-evolve: predefined agents can update SOUL.md through chat + selfEvolve bool + // Group writer cache for system prompt injection groupWriterCache *store.GroupWriterCache @@ -185,6 +188,9 @@ type LoopConfig struct { // Thinking level: "off", "low", "medium", "high" (from agent other_config) ThinkingLevel string + // Self-evolve: predefined agents can update SOUL.md (style/tone) through chat + SelfEvolve bool + // Group writer cache for system prompt injection GroupWriterCache *store.GroupWriterCache @@ -253,6 +259,7 @@ func NewLoop(cfg LoopConfig) *Loop { maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, thinkingLevel: cfg.ThinkingLevel, + selfEvolve: cfg.SelfEvolve, groupWriterCache: cfg.GroupWriterCache, teamStore: cfg.TeamStore, mediaStore: cfg.MediaStore, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 7f94a336..a7dcefc5 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -371,6 +371,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, ThinkingLevel: ag.ParseThinkingLevel(), + SelfEvolve: ag.ParseSelfEvolve(), GroupWriterCache: deps.GroupWriterCache, TeamStore: deps.TeamStore, MediaStore: deps.MediaStore, diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 687f12c3..1e9c7cd3 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -42,6 +42,9 @@ type SystemPromptConfig struct { SandboxEnabled bool // exec tool runs inside Docker sandbox? SandboxContainerDir string // container-side workdir (e.g. "/workspace") SandboxWorkspaceAccess string // "none", "ro", "rw" + + // Self-evolution: predefined agents can update SOUL.md (style/tone) + SelfEvolve bool } // coreToolSummaries maps tool names to one-line descriptions. @@ -106,6 +109,11 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { // 3. ## Safety lines = append(lines, buildSafetySection()...) + // 3.5. ## Self-Evolution (predefined agents with self_evolve enabled) + if cfg.SelfEvolve && cfg.AgentType == "predefined" { + lines = append(lines, buildSelfEvolveSection()...) + } + // 4. ## Skills (full only) // SkillsSummary non-empty → inline mode (XML list in prompt, TS-style) // SkillsSummary empty + HasSkillSearch → search mode (use skill_search tool) @@ -234,6 +242,28 @@ func buildSafetySection() []string { } } +func buildSelfEvolveSection() []string { + return []string{ + "## Self-Evolution", + "", + "You have self-evolution enabled. You may update your SOUL.md file to refine your communication style over time.", + "", + "What you CAN evolve in SOUL.md:", + "- Tone, voice, and manner of speaking", + "- Response style and formatting preferences", + "- Vocabulary and phrasing patterns", + "- Interaction patterns based on user feedback", + "", + "What you MUST NOT change:", + "- Your name, identity, or contact information", + "- Your core purpose or role", + "- Any content in IDENTITY.md or AGENTS.md (these remain locked)", + "", + "Make changes incrementally. Only update SOUL.md when you notice clear patterns in user feedback or interaction style preferences.", + "", + } +} + func buildSkillsSection(skillsSummary string, hasSkillSearch bool) []string { if skillsSummary != "" { // Inline mode: skills XML is in the prompt (like TS). diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index 2b31835f..f59dfadf 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -101,6 +101,9 @@ func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ s func (s *seedStubStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) { return nil, nil } +func (s *seedStubStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) { + return nil, nil +} // ---- Tests ---- diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 0f81cc0b..ab5b5e5d 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -95,6 +95,9 @@ func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID func (s *createCaptureStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) { return nil, nil } +func (s *createCaptureStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) { + return nil, nil +} // ---- helpers ---- diff --git a/internal/http/agents.go b/internal/http/agents.go index 582f9ff6..bafa83ef 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -58,6 +58,9 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare)) mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate)) mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon)) + mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances)) + mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles)) + mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile)) } func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { diff --git a/internal/http/agents_instances.go b/internal/http/agents_instances.go new file mode 100644 index 00000000..a1d0d7a1 --- /dev/null +++ b/internal/http/agents_instances.go @@ -0,0 +1,128 @@ +package http + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleListInstances returns all user instances for a predefined agent. +func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + + ag, err := h.agents.GetByID(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"}) + return + } + if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instances"}) + return + } + + instances, err := h.agents.ListUserInstances(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"instances": instances}) +} + +// handleGetInstanceFiles returns user context files for a specific instance. +func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Request) { + callerID := store.UserIDFromContext(r.Context()) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + instanceUserID := r.PathValue("userID") + if instanceUserID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"}) + return + } + + ag, err := h.agents.GetByID(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"}) + return + } + if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instance files"}) + return + } + + files, err := h.agents.GetUserContextFiles(r.Context(), id, instanceUserID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) +} + +// handleSetInstanceFile updates a user context file for a specific instance. +func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Request) { + callerID := store.UserIDFromContext(r.Context()) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + instanceUserID := r.PathValue("userID") + fileName := r.PathValue("fileName") + if instanceUserID == "" || fileName == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID or fileName"}) + return + } + + // Only USER.md can be edited via this endpoint — other files are managed by the agent itself. + if fileName != "USER.md" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only USER.md can be edited via this endpoint"}) + return + } + + ag, err := h.agents.GetByID(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"}) + return + } + if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance files"}) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"}) + return + } + var payload struct { + Content string `json:"content"` + } + if err := json.Unmarshal(body, &payload); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if err := h.agents.SetUserContextFile(r.Context(), id, instanceUserID, fileName, payload.Content); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Invalidate caches so the agent picks up the change immediately + h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String()) + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 0b672bd9..3918ae9d 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -136,6 +136,21 @@ func (a *AgentData) ParseThinkingLevel() string { return cfg.ThinkingLevel } +// ParseSelfEvolve extracts self_evolve from other_config JSONB. +// When true, predefined agents can update their SOUL.md (style/tone) through chat. +func (a *AgentData) ParseSelfEvolve() bool { + if len(a.OtherConfig) == 0 { + return false + } + var cfg struct { + SelfEvolve bool `json:"self_evolve"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil { + return false + } + return cfg.SelfEvolve +} + // AgentShareData represents an agent share grant. type AgentShareData struct { BaseModel @@ -196,8 +211,9 @@ type AgentStore interface { GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*UserAgentOverrideData, error) SetUserOverride(ctx context.Context, override *UserAgentOverrideData) error - // User-agent profiles + // User-agent profiles + instances GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error) + ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error) // Group file writers (allowlist for protected file edits in group chats) IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error) @@ -207,6 +223,14 @@ type AgentStore interface { ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]GroupWriterGroupInfo, error) } +// UserInstanceData represents a user instance for a predefined agent. +type UserInstanceData struct { + UserID string `json:"user_id"` + FirstSeenAt *string `json:"first_seen_at,omitempty"` + LastSeenAt *string `json:"last_seen_at,omitempty"` + FileCount int `json:"file_count"` +} + // GroupFileWriterData represents a group file writer entry. type GroupFileWriterData struct { UserID string `json:"user_id"` diff --git a/internal/store/context.go b/internal/store/context.go index 47aead48..c27c2a86 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -19,6 +19,8 @@ const ( // In group chats, UserIDKey is group-scoped but SenderIDKey preserves // the actual person who sent the message. SenderIDKey contextKey = "goclaw_sender_id" + // SelfEvolveKey indicates whether a predefined agent can update its SOUL.md. + SelfEvolveKey contextKey = "goclaw_self_evolve" ) // WithUserID returns a new context with the given user ID. @@ -72,3 +74,16 @@ func SenderIDFromContext(ctx context.Context) string { } return "" } + +// WithSelfEvolve returns a new context with the self-evolve flag. +func WithSelfEvolve(ctx context.Context, v bool) context.Context { + return context.WithValue(ctx, SelfEvolveKey, v) +} + +// SelfEvolveFromContext extracts the self-evolve flag from context. Returns false if not set. +func SelfEvolveFromContext(ctx context.Context) bool { + if v, ok := ctx.Value(SelfEvolveKey).(bool); ok { + return v + } + return false +} diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index 7ee00048..2e682673 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -110,6 +110,40 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. return isInserted, ws, nil } +// --- User Instances --- + +func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT p.user_id, + TO_CHAR(p.first_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS first_seen_at, + TO_CHAR(p.last_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS last_seen_at, + COALESCE(fc.cnt, 0) AS file_count + FROM user_agent_profiles p + LEFT JOIN ( + SELECT user_id, COUNT(*) AS cnt + FROM user_context_files + WHERE agent_id = $1 + GROUP BY user_id + ) fc ON fc.user_id = p.user_id + WHERE p.agent_id = $1 + ORDER BY p.last_seen_at DESC + `, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.UserInstanceData + for rows.Next() { + var d store.UserInstanceData + if err := rows.Scan(&d.UserID, &d.FirstSeenAt, &d.LastSeenAt, &d.FileCount); err != nil { + continue + } + result = append(result, d) + } + return result, nil +} + // --- User Overrides --- func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) { diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 0cdc4a59..f47a97ee 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -287,13 +287,26 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st } // Predefined agent: block writes to shared files (only USER.md allowed per-user). - // This prevents any user from modifying the agent's identity/behavior via chat. + // Exception: SOUL.md is allowed when self_evolve is enabled (style/tone evolution). if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile { - return true, fmt.Errorf( - "this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+ - "Only the agent owner can edit it from the management dashboard.", - fileName, + allowSoulEvolve := fileName == bootstrap.SoulFile && store.SelfEvolveFromContext(ctx) + if !allowSoulEvolve { + return true, fmt.Errorf( + "this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+ + "Only the agent owner can edit it from the management dashboard.", + fileName, + ) + } + // SOUL.md with self_evolve: write to agent-level (shared across all users) + slog.Info("self-evolve: SOUL.md updated", + "agent_id", agentID, + "user_id", userID, ) + err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content) + if err == nil { + b.InvalidateAgent(agentID) + } + return true, err } // Open agent: all files per-user @@ -438,6 +451,11 @@ func (b *ContextFileInterceptor) hasBootstrapFile(ctx context.Context, agentID u return err == nil && content != "" } +// InvalidateUser clears the per-user cache for a specific agent+user combination. +func (b *ContextFileInterceptor) InvalidateUser(agentID uuid.UUID, userID string) { + b.invalidateUser(agentID, userID) +} + func (b *ContextFileInterceptor) invalidateUser(agentID uuid.UUID, userID string) { b.userCache.Delete(context.Background(), agentID.String()+":"+userID) } diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index e467c268..a01e8e35 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -84,6 +84,9 @@ func (s *stubAgentStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ func (s *stubAgentStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) { return nil, nil } +func (s *stubAgentStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) { + return nil, nil +} // ---- Tests ---- diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 88a97698..64825064 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -8,6 +8,7 @@ export const queryKeys = { detail: (id: string) => ["agents", id] as const, files: (agentKey: string) => ["agents", agentKey, "files"] as const, links: (agentId: string) => ["agents", agentId, "links"] as const, + instances: (agentId: string) => ["agents", agentId, "instances"] as const, }, sessions: { all: ["sessions"] as const, diff --git a/ui/web/src/pages/agents/agent-card.tsx b/ui/web/src/pages/agents/agent-card.tsx index 90372fe8..9e7a76f1 100644 --- a/ui/web/src/pages/agents/agent-card.tsx +++ b/ui/web/src/pages/agents/agent-card.tsx @@ -1,6 +1,7 @@ -import { Bot, Star, RotateCcw, Trash2 } from "lucide-react"; +import { Bot, Star, RotateCcw, Trash2, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { AgentData } from "@/types/agent"; interface AgentCardProps { @@ -15,6 +16,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardProps) { const displayName = agent.display_name || (UUID_RE.test(agent.agent_key) ? "Unnamed Agent" : agent.agent_key); + const selfEvolve = agent.agent_type === "predefined" && Boolean((agent.other_config as Record | null)?.self_evolve); // Show agent_key as subtitle only if there's a display_name and agent_key is meaningful const showSubtitle = agent.display_name && !UUID_RE.test(agent.agent_key); @@ -72,7 +74,34 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro {/* Bottom badges */}
- {agent.agent_type} + + + {agent.agent_type} + + + {agent.agent_type === "predefined" + ? "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances." + : "Each user gets their own full set of context files."} + + + {agent.agent_type === "predefined" && ( + + + + + {selfEvolve ? "Evolving" : "Static"} + + + + {selfEvolve + ? "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed." + : "Agent style is static. Enable Self-Evolution in settings to allow style adaptation."} + + + )} {agent.context_window > 0 && ( {(agent.context_window / 1000).toFixed(0)}K ctx diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index f0d53573..42960a4a 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -40,6 +41,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const [model, setModel] = useState(""); const [agentType, setAgentType] = useState<"open" | "predefined">("open"); const [description, setDescription] = useState(""); + const [selfEvolve, setSelfEvolve] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -75,13 +77,16 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD setLoading(true); setError(""); try { + const otherConfig: Record = {}; + if (description.trim()) otherConfig.description = description.trim(); + if (selfEvolve) otherConfig.self_evolve = true; await onCreate({ agent_key: agentKey.trim(), display_name: displayName.trim() || undefined, provider: provider.trim(), model: model.trim(), agent_type: agentType, - other_config: description.trim() ? { description: description.trim() } : undefined, + other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined, }); onOpenChange(false); setAgentKey(""); @@ -91,6 +96,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD setModel(""); setAgentType("open"); setDescription(""); + setSelfEvolve(false); setError(""); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create agent"); @@ -252,6 +258,13 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD AI will automatically generate your agent's context files from this description. Leave empty to start with templates.

+
+
+ +

Allow agent to evolve its style and tone over time via SOUL.md

+
+ +
)} {error && ( diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index a7ba76f1..45b444e8 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Bot, Star, Trash2 } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ArrowLeft, Bot, Star, Trash2, Sparkles } from "lucide-react"; import { useAgentDetail } from "../hooks/use-agent-detail"; import { useAgents } from "../hooks/use-agents"; import { AgentGeneralTab } from "./agent-general-tab"; @@ -11,6 +12,7 @@ import { AgentFilesTab } from "./agent-files-tab"; import { AgentSharesTab } from "./agent-shares-tab"; import { AgentLinksTab } from "./agent-links-tab"; import { AgentSkillsTab } from "./agent-skills-tab"; +import { AgentInstancesTab } from "./agent-instances-tab"; import { SummoningModal } from "../summoning-modal"; import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; @@ -68,6 +70,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { const subtitle = agentSubtitle(agent); return ( +
{/* Header */}
@@ -94,7 +97,37 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { | )} - {agent.agent_type} + + + {agent.agent_type} + + + {agent.agent_type === "predefined" + ? "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances." + : "Each user gets their own full set of context files."} + + + {agent.agent_type === "predefined" && (() => { + const evolving = Boolean((agent.other_config as Record | null)?.self_evolve); + return ( + + + + + {evolving ? "Evolving" : "Static"} + + + + {evolving + ? "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed." + : "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation."} + + + ); + })()} {agent.provider && ( <> | @@ -123,6 +156,9 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { Shares Links Skills + {agent.agent_type === "predefined" && ( + Instances + )} @@ -156,6 +192,12 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { + {agent.agent_type === "predefined" && ( + + + + )} +
@@ -182,5 +224,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { }} />
+
); } diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx index 1fa6d46f..8f6ded48 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx @@ -1,7 +1,9 @@ import { useState, useCallback } from "react"; -import { Save, Check, AlertCircle } from "lucide-react"; +import { Save, Check, AlertCircle, Sparkles, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; import type { AgentData } from "@/types/agent"; import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections"; @@ -27,6 +29,10 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { // Workspace const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace); + // Self-evolve (predefined agents only) + const otherCfg = (agent.other_config ?? {}) as Record; + const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve)); + // Save state const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -41,6 +47,8 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { setSaveError(null); setSaved(false); try { + // Merge self_evolve into other_config without losing other keys + const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve }; await onUpdate({ display_name: displayName, frontmatter: frontmatter || null, @@ -51,6 +59,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { restrict_to_workspace: restrictToWorkspace, status, is_default: isDefault, + other_config: updatedOtherConfig, }); setSaved(true); setTimeout(() => setSaved(false), 3000); @@ -99,6 +108,40 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { onRestrictChange={setRestrictToWorkspace} /> + {/* Self-Evolve (predefined agents only) */} + {agent.agent_type === "predefined" && ( + <> + +
+
+ +

Self-Evolution

+
+
+
+ +

+ When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked. +

+
+ +
+ {selfEvolve && ( +
+ + Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed. +
+ )} +
+ + )} + {/* Save */} {saveError && (
diff --git a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx new file mode 100644 index 00000000..feef3d38 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from "react"; +import { Save, Check, AlertCircle, Users, FileText } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances"; + +interface AgentInstancesTabProps { + agentId: string; +} + +export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) { + const { instances, loading, saving, getFiles, setFile } = useAgentInstances(agentId); + const [selected, setSelected] = useState(null); + const [content, setContent] = useState(""); + const [originalContent, setOriginalContent] = useState(""); + const [loadingFiles, setLoadingFiles] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + // Load USER.md when an instance is selected + useEffect(() => { + if (!selected) return; + let cancelled = false; + setLoadingFiles(true); + setError(null); + getFiles(selected).then((files) => { + if (cancelled) return; + const userFile = files.find((f) => f.file_name === "USER.md"); + const c = userFile?.content ?? ""; + setContent(c); + setOriginalContent(c); + }).catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load files"); + }).finally(() => { + if (!cancelled) setLoadingFiles(false); + }); + return () => { cancelled = true; }; + }, [selected, getFiles]); + + const handleSave = async () => { + if (!selected) return; + setError(null); + setSaved(false); + try { + await setFile(selected, "USER.md", content); + setOriginalContent(content); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save"); + } + }; + + const isDirty = content !== originalContent; + + if (loading) { + return
Loading instances...
; + } + + if (instances.length === 0) { + return ( +
+ +

No user instances yet.

+

Instances are created when users interact with this agent.

+
+ ); + } + + return ( +
+ {/* Instance list */} +
+
+ {instances.length} instance{instances.length !== 1 ? "s" : ""} +
+ {instances.map((inst) => ( + setSelected(inst.user_id)} + /> + ))} +
+ + {/* Editor */} +
+ {!selected ? ( +
+ Select an instance to view and edit its USER.md +
+ ) : loadingFiles ? ( +
+ Loading... +
+ ) : ( + <> +
+ + USER.md + — {selected} +
+