From 08ced252b22d800797a3d7c4825cb96b6dccee08 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 24 Feb 2026 10:19:49 +0700 Subject: [PATCH] feat: Introduce agent summoning flow with a dedicated modal and updated bootstrap process for predefined agents. --- .gitignore | 4 +- cmd/gateway.go | 25 +- cmd/gateway_managed.go | 8 +- internal/agent/loop.go | 27 ++ internal/agent/loop_history.go | 10 +- internal/agent/resolver.go | 14 +- internal/agent/systemprompt.go | 5 +- internal/bootstrap/seed_store.go | 17 +- .../templates/BOOTSTRAP_PREDEFINED.md | 44 +++ internal/http/agents.go | 109 +++++++- internal/http/summoner.go | 263 ++++++++++++++++++ internal/store/pg/channel_instances.go | 15 +- internal/tools/context_file_interceptor.go | 36 ++- internal/tools/context_keys.go | 10 + internal/tools/dynamic_tool.go | 8 +- internal/tools/edit.go | 8 +- internal/tools/filesystem.go | 8 +- internal/tools/filesystem_list.go | 8 +- internal/tools/filesystem_write.go | 8 +- internal/tools/shell.go | 6 +- pkg/protocol/events.go | 3 + ui/web/package.json | 1 + ui/web/src/lib/slug.ts | 2 +- ui/web/src/pages/agents/agent-card.tsx | 12 +- .../src/pages/agents/agent-create-dialog.tsx | 36 ++- .../agents/agent-detail/agent-detail-page.tsx | 3 +- .../agents/agent-detail/agent-files-tab.tsx | 248 ++++++++++++----- .../agents/agent-detail/agent-general-tab.tsx | 20 +- ui/web/src/pages/agents/agent-presets.ts | 88 ++++++ ui/web/src/pages/agents/agents-page.tsx | 30 +- .../pages/agents/hooks/use-agent-detail.ts | 10 +- ui/web/src/pages/agents/summoning-modal.tsx | 197 +++++++++++++ .../channels/channel-instance-form-dialog.tsx | 2 +- .../channels/hooks/use-channel-instances.ts | 2 +- ui/web/tsconfig.tsbuildinfo | 2 +- 35 files changed, 1142 insertions(+), 147 deletions(-) create mode 100644 internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md create mode 100644 internal/http/summoner.go create mode 100644 ui/web/src/pages/agents/agent-presets.ts create mode 100644 ui/web/src/pages/agents/summoning-modal.tsx diff --git a/.gitignore b/.gitignore index fdd7bb78..5a08b96f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ ui/web/pnpm-lock.yaml tmp .claude/ -deploy-*.sh \ No newline at end of file +deploy-*.sh + +docs/technical-diaries \ No newline at end of file diff --git a/cmd/gateway.go b/cmd/gateway.go index 4fca07f4..9d587471 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -557,7 +557,7 @@ func runGateway() { } wireManagedExtras(managedStores, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader) - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg) + agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry) if agentsH != nil { server.SetAgentsHandler(agentsH) } @@ -614,7 +614,6 @@ func runGateway() { // Managed mode: load channel instances from DB first. var instanceLoader *channels.InstanceLoader - var loadedNames map[string]struct{} if managedStores != nil && managedStores.ChannelInstances != nil { instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore) instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents)) @@ -625,21 +624,17 @@ func runGateway() { if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) } - loadedNames = instanceLoader.LoadedNames() } - // Register config-based channels as fallback. - // Telegram: kept for backward compat (only channel with production data). - // Other channels: skip config registration in managed mode if any DB instances loaded. - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" { - if _, loaded := loadedNames["telegram"]; !loaded { - tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil) - if err != nil { - slog.Error("failed to initialize telegram channel", "error", err) - } else { - channelMgr.RegisterChannel("telegram", tg) - slog.Info("telegram channel enabled (config)") - } + // Register config-based channels as fallback (standalone mode only). + // In managed mode, channels are loaded from DB via instanceLoader — skip config-based registration. + if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil { + tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil) + if err != nil { + slog.Error("failed to initialize telegram channel", "error", err) + } else { + channelMgr.RegisterChannel("telegram", tg) + slog.Info("telegram channel enabled (config)") } } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 95eac4db..8504e635 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -271,7 +271,7 @@ func wireManagedExtras( } // wireManagedHTTP creates managed-mode HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers). -func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) { +func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -281,7 +281,11 @@ func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, var providersH *httpapi.ProvidersHandler if stores != nil && stores.Agents != nil { - agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus) + var summoner *httpapi.AgentSummoner + if providerReg != nil { + summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) + } + agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner) } if stores != nil && stores.Skills != nil { diff --git a/internal/agent/loop.go b/internal/agent/loop.go index c1a57bf6..ba2b6019 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "os" + "path/filepath" "sort" "strings" "sync" @@ -323,6 +325,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) ctx = store.WithSenderID(ctx, req.SenderID) } + // Per-user workspace isolation (managed mode only). + // Each user gets a subdirectory within the agent's workspace. + if l.agentUUID != uuid.Nil && l.workspace != "" { + effectiveWorkspace := l.workspace + if req.UserID != "" { + effectiveWorkspace = filepath.Join(l.workspace, sanitizePathSegment(req.UserID)) + os.MkdirAll(effectiveWorkspace, 0755) + } + ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace) + } + // Ensure per-user context files exist (first-chat seeding, managed mode) if l.ensureUserFiles != nil && req.UserID != "" { if _, loaded := l.seededUsers.LoadOrStore(req.UserID, true); !loaded { @@ -653,3 +666,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Usage: &totalUsage, }, nil } + +// sanitizePathSegment makes a userID safe for use as a directory name. +// Replaces colons, spaces, and other unsafe chars with underscores. +func sanitizePathSegment(s string) string { + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 8977d7b1..895a3da4 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/providers" ) @@ -22,10 +24,16 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s _, hasSpawn := l.tools.Get("spawn") _, hasSkillSearch := l.tools.Get("skill_search") + // Per-user workspace: show the user's subdirectory in the system prompt (managed mode) + promptWorkspace := l.workspace + if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" { + promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID)) + } + systemPrompt := BuildSystemPrompt(SystemPromptConfig{ AgentID: l.id, Model: l.model, - Workspace: l.workspace, + Workspace: promptWorkspace, Channel: channel, OwnerIDs: l.ownerIDs, Mode: mode, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 619dfbb0..c100fbea 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "os" + "path/filepath" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" @@ -113,6 +115,16 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { sandboxWorkspaceAccess = string(resolved.WorkspaceAccess) } + // Expand ~ in workspace path and ensure directory exists + workspace := ag.Workspace + if workspace != "" { + workspace = config.ExpandHome(workspace) + if !filepath.IsAbs(workspace) { + workspace, _ = filepath.Abs(workspace) + } + os.MkdirAll(workspace, 0755) + } + // Per-agent custom tools (clone registry if agent has custom tools) toolsReg := deps.Tools if deps.DynamicLoader != nil { @@ -131,7 +143,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { Model: ag.Model, ContextWindow: contextWindow, MaxIterations: maxIter, - Workspace: ag.Workspace, + Workspace: workspace, Bus: deps.Bus, Sessions: deps.Sessions, Tools: toolsReg, diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 11a1032c..ca4ff79d 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -83,9 +83,8 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string { lines = append(lines, "## FIRST RUN — MANDATORY", "", - "BOOTSTRAP.md is loaded below in Project Context. This is your FIRST TIME running.", - "You MUST follow BOOTSTRAP.md instructions: introduce yourself, ask who the user is,", - "figure out your name/creature/vibe/emoji together, then update IDENTITY.md and USER.md.", + "BOOTSTRAP.md is loaded below in Project Context. This is your FIRST interaction with this user.", + "You MUST follow BOOTSTRAP.md instructions immediately.", "Do NOT give a generic greeting. Do NOT ignore this. Read BOOTSTRAP.md and follow it NOW.", "", ) diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index be231137..cdb68172 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -36,6 +36,11 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. var seeded []string for _, name := range templateFiles { + // USER.md is always per-user for predefined agents — don't seed at agent level. + // Having it at agent level alongside the user copy can confuse the model. + if name == UserFile { + continue + } if hasContent[name] { continue } @@ -71,13 +76,15 @@ var userSeedFilesOpen = []string{ } // userSeedFilesPredefined is the set of files seeded per-user for predefined agents. +// Includes BOOTSTRAP.md for user onboarding (uses BOOTSTRAP_PREDEFINED.md template). var userSeedFilesPredefined = []string{ UserFile, + BootstrapFile, } // SeedUserFiles seeds embedded templates into user_context_files for a new user. // For "open" agents: all 7 files (including BOOTSTRAP.md). -// For "predefined" agents: only USER.md. +// For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template). // Only writes files that don't already exist — safe to call multiple times. // Returns the list of file names that were seeded. func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) { @@ -104,7 +111,13 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui continue // already has content, don't overwrite } - content, err := templateFS.ReadFile(filepath.Join("templates", name)) + // Predefined agents use a user-focused bootstrap template + templateName := name + if agentType == "predefined" && name == BootstrapFile { + templateName = "BOOTSTRAP_PREDEFINED.md" + } + + content, err := templateFS.ReadFile(filepath.Join("templates", templateName)) if err != nil { slog.Warn("bootstrap: failed to read embedded template for user seed", "file", name, "error", err) continue diff --git a/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md b/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md new file mode 100644 index 00000000..899c37b8 --- /dev/null +++ b/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md @@ -0,0 +1,44 @@ +# BOOTSTRAP.md - Welcome, New User + +_A new user just started chatting with you. Time to get to know them._ + +## The Conversation + +Don't interrogate. Don't be robotic. Just... talk. + +Start by introducing yourself briefly — your name, what you do, how you can help. +Then get to know them naturally: + +1. **Their name** — What should you call them? +2. **Their language** — What language do they prefer? (Switch to it if needed) +3. **Their timezone** — Where are they? (Helps with scheduling and context) +4. **Their needs** — What are they hoping you can help with? + +Keep it conversational. One or two questions at a time, not a form. +Match the user's tone and language — if they're casual, be casual back. + +## After You Know Them + +Update `USER.md` immediately with what you learned. Use the `write_file` tool NOW: + +``` +write_file("USER.md", "# USER.md - About Your Human\n\n- **Name:** (their name)\n- **What to call them:** (how they want to be addressed)\n- **Pronouns:** (if shared)\n- **Timezone:** (their timezone)\n- **Language:** (their preferred language)\n- **Notes:** (anything else you learned)\n") +``` + +Do NOT skip this step. Do NOT just say you noted it — actually call `write_file` to save it. + +## When You're Done + +Mark bootstrap as complete by writing empty content to this file: + +``` +write_file("BOOTSTRAP.md", "") +``` + +Do NOT use `rm` or `exec` to delete it. The empty write signals the system that onboarding is finished. + +You MUST call both `write_file` calls above before moving on to normal conversation. + +--- + +_Make a good first impression._ diff --git a/internal/http/agents.go b/internal/http/agents.go index 313387bf..fb45ebd2 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -2,6 +2,7 @@ package http import ( "encoding/json" + "fmt" "log/slog" "net/http" @@ -15,14 +16,15 @@ import ( // AgentsHandler handles agent CRUD and sharing endpoints (managed mode only). type AgentsHandler struct { - agents store.AgentStore - token string - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + agents store.AgentStore + token string + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) } // NewAgentsHandler creates a handler for agent management endpoints. -func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus) *AgentsHandler { - return &AgentsHandler{agents: agents, token: token, msgBus: msgBus} +func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner) *AgentsHandler { + return &AgentsHandler{agents: agents, token: token, msgBus: msgBus, summoner: summoner} } // emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set. @@ -46,6 +48,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares)) mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare)) mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare)) + mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate)) } func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -101,23 +104,48 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } req.OwnerID = userID - if req.Status == "" { - req.Status = "active" - } if req.AgentType == "" { req.AgentType = "open" } + if req.ContextWindow <= 0 { + req.ContextWindow = 200000 + } + if req.MaxToolIterations <= 0 { + req.MaxToolIterations = 20 + } + if req.Workspace == "" { + if req.IsDefault { + req.Workspace = "~/.goclaw/workspace" + } else { + req.Workspace = fmt.Sprintf("~/.goclaw/%s-workspace", req.AgentKey) + } + } + req.RestrictToWorkspace = true + + // Check if predefined agent has a description for LLM summoning + description := extractDescription(req.OtherConfig) + if req.AgentType == "predefined" && description != "" && h.summoner != nil { + req.Status = "summoning" + } else if req.Status == "" { + req.Status = "active" + } if err := h.agents.Create(r.Context(), &req); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - // Seed context files into agent_context_files (skipped for open agents) + // Seed context files into agent_context_files (skipped for open agents). + // For summoning agents, templates serve as fallback if LLM fails. if _, err := bootstrap.SeedToStore(r.Context(), h.agents, req.ID, req.AgentType); err != nil { slog.Warn("failed to seed context files for new agent", "agent", req.AgentKey, "error", err) } + // Start LLM summoning in background if applicable + if req.Status == "summoning" { + go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description) + } + writeJSON(w, http.StatusCreated, req) } @@ -336,6 +364,69 @@ func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } +func (h *AgentsHandler) handleRegenerate(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 + } + + // Only owner can regenerate + 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 { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can regenerate agent"}) + return + } + if ag.Status == "summoning" { + writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"}) + return + } + if h.summoner == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"}) + return + } + + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if req.Prompt == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"}) + return + } + + // Set status to summoning + if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt) + + writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"}) +} + +// extractDescription pulls the description string from other_config JSONB. +func extractDescription(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var cfg map[string]interface{} + if json.Unmarshal(raw, &cfg) != nil { + return "" + } + desc, _ := cfg["description"].(string) + return desc +} + func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/http/summoner.go b/internal/http/summoner.go new file mode 100644 index 00000000..674873f3 --- /dev/null +++ b/internal/http/summoner.go @@ -0,0 +1,263 @@ +package http + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// summoningFiles is the ordered list of context files the LLM should generate. +var summoningFiles = []string{ + "SOUL.md", + "IDENTITY.md", + "AGENTS.md", + "TOOLS.md", + "HEARTBEAT.md", +} + +// fileTagRe parses content from LLM output. +var fileTagRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) + +// AgentSummoner generates context files for predefined agents using an LLM. +// Runs one-shot background calls — no session data, no agent loop. +type AgentSummoner struct { + agents store.AgentStore + providerReg *providers.Registry + msgBus *bus.MessageBus +} + +// NewAgentSummoner creates a summoner backed by the given stores and provider registry. +func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *AgentSummoner { + return &AgentSummoner{ + agents: agents, + providerReg: providerReg, + msgBus: msgBus, + } +} + +// SummonAgent generates context files from a natural language description. +// Meant to be called as a goroutine: go summoner.SummonAgent(...) +// On success: stores generated files and sets agent status to "active". +// On failure: keeps template files (already seeded) and sets status to "active". +func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + s.emitEvent(agentID, "started", "", "") + + files, err := s.generateFiles(ctx, providerName, model, s.buildCreatePrompt(description)) + if err != nil { + slog.Warn("summoning: LLM generation failed, falling back to templates", + "agent", agentID, "error", err) + s.emitEvent(agentID, "failed", "", err.Error()) + s.setAgentStatus(ctx, agentID, "active") + return + } + + s.storeFiles(ctx, agentID, files) + s.setAgentStatus(ctx, agentID, "active") + s.emitEvent(agentID, "completed", "", "") + + slog.Info("summoning: completed", "agent", agentID, "files", len(files)) +} + +// RegenerateAgent updates context files based on an edit prompt. +// Reads existing files, sends them + edit instructions to LLM, stores results. +// Synchronous — caller should run in goroutine if needed. +func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + s.emitEvent(agentID, "started", "", "") + + // Read existing files for context + existing, err := s.agents.GetAgentContextFiles(ctx, agentID) + if err != nil { + slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) + s.emitEvent(agentID, "failed", "", err.Error()) + s.setAgentStatus(ctx, agentID, "active") + return + } + + prompt := s.buildEditPrompt(existing, editPrompt) + + files, err := s.generateFiles(ctx, providerName, model, prompt) + if err != nil { + slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) + s.emitEvent(agentID, "failed", "", err.Error()) + s.setAgentStatus(ctx, agentID, "active") + return + } + + s.storeFiles(ctx, agentID, files) + s.setAgentStatus(ctx, agentID, "active") + s.emitEvent(agentID, "completed", "", "") + + slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) +} + +// generateFiles calls the LLM and parses the XML-tagged response into file map. +func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) { + provider, err := s.resolveProvider(providerName) + if err != nil { + return nil, fmt.Errorf("resolve provider: %w", err) + } + + resp, err := provider.Chat(ctx, providers.ChatRequest{ + Messages: []providers.Message{ + {Role: "user", Content: prompt}, + }, + Model: model, + Options: map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.7, + }, + }) + if err != nil { + return nil, fmt.Errorf("LLM call: %w", err) + } + + files := parseFileResponse(resp.Content) + if len(files) == 0 { + return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content)) + } + + return files, nil +} + +// storeFiles saves generated files to agent_context_files and emits progress events. +func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) { + for _, name := range summoningFiles { + content, ok := files[name] + if !ok || content == "" { + continue + } + if err := s.agents.SetAgentContextFile(ctx, agentID, name, content); err != nil { + slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) + continue + } + s.emitEvent(agentID, "file_generated", name, "") + } +} + +func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) { + if s.providerReg == nil { + return nil, fmt.Errorf("no provider registry") + } + + provider, err := s.providerReg.Get(name) + if err != nil { + // Fallback to first available provider + names := s.providerReg.List() + if len(names) == 0 { + return nil, fmt.Errorf("no providers configured") + } + provider, err = s.providerReg.Get(names[0]) + if err != nil { + return nil, err + } + slog.Warn("summoning: provider not found, using fallback", "wanted", name, "using", names[0]) + } + return provider, nil +} + +func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) { + if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil { + slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err) + } +} + +func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) { + if s.msgBus == nil { + return + } + payload := map[string]interface{}{ + "type": eventType, + "agent_id": agentID.String(), + } + if fileName != "" { + payload["file"] = fileName + } + if errMsg != "" { + payload["error"] = errMsg + } + s.msgBus.Broadcast(bus.Event{ + Name: protocol.EventAgentSummoning, + Payload: payload, + }) +} + +// buildCreatePrompt constructs the system + user prompt for initial file generation. +func (s *AgentSummoner) buildCreatePrompt(description string) string { + return fmt.Sprintf(`You are setting up a new AI assistant. Based on the description below, generate the content for these context files. Write in the same language as the description. + + +%s + + +Generate each file inside XML tags. Follow the format and spirit of each file type: + +- **SOUL.md**: Who the agent IS. Core personality traits, communication style, boundaries, and values. Written in first person, like a character sheet. Not a job description — a soul. +- **IDENTITY.md**: Quick identity card. Name, Creature (what kind of entity?), Vibe (how they come across), Emoji (signature), Avatar (leave blank). +- **AGENTS.md**: Operating instructions. How the agent should behave each session, handle memory, manage safety, work in groups. Keep the core structure from the template but personalize for this agent's purpose. +- **TOOLS.md**: Notes about tools relevant to this agent's role. What environment-specific details matter. +- **HEARTBEAT.md**: Periodic tasks (if the agent should check things proactively). Leave minimal/empty if not applicable. + + +(generate here) + + +(generate here) + + +(generate here) + + +(generate here) + + +(generate here) +`, description) +} + +// buildEditPrompt constructs the prompt for editing existing files. +func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string { + var sb strings.Builder + sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n\n") + for _, f := range existing { + if f.Content == "" { + continue + } + fmt.Fprintf(&sb, "\n%s\n\n", f.FileName, f.Content) + } + sb.WriteString("\n\n") + fmt.Fprintf(&sb, "\n%s\n\n\n", editPrompt) + sb.WriteString("Generate updated files. Only include files that need changes. Keep the same XML format:\n\n") + sb.WriteString("\n(updated content, or omit if unchanged)\n\n") + sb.WriteString("...\n") + return sb.String() +} + +// parseFileResponse extracts file contents from XML-tagged LLM output. +func parseFileResponse(content string) map[string]string { + files := make(map[string]string) + matches := fileTagRe.FindAllStringSubmatch(content, -1) + for _, m := range matches { + name := strings.TrimSpace(m[1]) + body := strings.TrimSpace(m[2]) + if name != "" && body != "" { + files[name] = body + } + } + return files +} diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index fe0c08f6..41a372fa 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -3,6 +3,7 @@ package pg import ( "context" "database/sql" + "encoding/json" "fmt" "log/slog" "time" @@ -144,8 +145,18 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { // Encrypt credentials if present - if credsVal, ok := updates["credentials"]; ok { - if credsStr, isStr := credsVal.(string); isStr && credsStr != "" && s.encKey != "" { + if credsVal, ok := updates["credentials"]; ok && credsVal != nil { + var credsStr string + switch v := credsVal.(type) { + case string: + credsStr = v + default: + // Object/map from JSON — marshal to string for encryption + if b, err := json.Marshal(v); err == nil { + credsStr = string(b) + } + } + if credsStr != "" && s.encKey != "" { encrypted, err := crypto.Encrypt(credsStr, s.encKey) if err != nil { return fmt.Errorf("encrypt credentials: %w", err) diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go index 60865588..e6f888db 100644 --- a/internal/tools/context_file_interceptor.go +++ b/internal/tools/context_file_interceptor.go @@ -101,7 +101,7 @@ func NewContextFileInterceptor(as store.AgentStore, workspace string) *ContextFi // ReadFile attempts to read a context file from the DB (with cache). // Routes based on agent type from context: // - "open": all files per-user → fallback to agent-level -// - "predefined": only USER.md per-user → all others agent-level +// - "predefined": USER.md + BOOTSTRAP.md per-user → all others agent-level // // Returns (content, true, nil) if handled, or ("", false, nil) if not a context file. func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) { @@ -131,8 +131,8 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str return b.readAgentFile(ctx, agentID, fileName) } - // Predefined agent: only USER.md per-user - if agentType == "predefined" && userID != "" && fileName == "USER.md" { + // Predefined agent: USER.md and BOOTSTRAP.md per-user + if agentType == "predefined" && userID != "" && (fileName == "USER.md" || fileName == "BOOTSTRAP.md") { content, handled, err := b.readUserFile(ctx, agentID, userID, fileName) if err != nil { return "", handled, err @@ -262,7 +262,9 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st // senderID empty = system context (cron, subagent) → fail open } - // BOOTSTRAP.md deletion: empty content = first-run completed → delete row + // BOOTSTRAP.md deletion: empty content = first-run completed → delete row. + // Must come BEFORE the predefined write block so bootstrap completion works + // for both open and predefined agents. if fileName == "BOOTSTRAP.md" && content == "" && userID != "" { err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName) if err == nil { @@ -271,6 +273,16 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st return true, err } + // 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. + if agentType == "predefined" && fileName != "USER.md" { + 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, + ) + } + // Open agent: all files per-user if agentType == "open" && userID != "" { err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content) @@ -353,6 +365,22 @@ func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID u Content: content, }) } + + // Include user-only files not present at agent level + // (e.g. BOOTSTRAP.md — seeded per-user for onboarding, not at agent level) + agentFileSet := make(map[string]bool, len(agentFiles)) + for _, f := range agentFiles { + agentFileSet[f.FileName] = true + } + for _, f := range userFiles { + if !agentFileSet[f.FileName] && f.Content != "" { + result = append(result, bootstrap.ContextFile{ + Path: f.FileName, + Content: f.Content, + }) + } + } + return result } diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 6c7544f0..acb4a1cc 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -15,6 +15,7 @@ const ( ctxPeerKind toolContextKey = "tool_peer_kind" ctxSandboxKey toolContextKey = "tool_sandbox_key" ctxAsyncCB toolContextKey = "tool_async_cb" + ctxWorkspace toolContextKey = "tool_workspace" ) func WithToolChannel(ctx context.Context, channel string) context.Context { @@ -61,3 +62,12 @@ func ToolAsyncCBFromCtx(ctx context.Context) AsyncCallback { v, _ := ctx.Value(ctxAsyncCB).(AsyncCallback) return v } + +func WithToolWorkspace(ctx context.Context, ws string) context.Context { + return context.WithValue(ctx, ctxWorkspace, ws) +} + +func ToolWorkspaceFromCtx(ctx context.Context) string { + v, _ := ctx.Value(ctxWorkspace).(string) + return v +} diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go index b6d00a36..8e14741a 100644 --- a/internal/tools/dynamic_tool.go +++ b/internal/tools/dynamic_tool.go @@ -58,8 +58,12 @@ func (t *DynamicTool) Execute(ctx context.Context, args map[string]interface{}) ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Working directory - cwd := t.workspace + // Working directory — per-user workspace from context, fallback to tool's workspace. + // Explicit WorkingDir on the tool definition still overrides everything. + cwd := ToolWorkspaceFromCtx(ctx) + if cwd == "" { + cwd = t.workspace + } if t.def.WorkingDir != "" { cwd = t.def.WorkingDir } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 4c6499be..1558f5c3 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -130,8 +130,12 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]interface{}) *Re return t.executeInSandbox(ctx, path, oldStr, newStr, replaceAll, sandboxKey) } - // Host execution - resolved, err := resolvePath(path, t.workspace, t.restrict) + // Host execution — use per-user workspace from context if available (managed mode) + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + resolved, err := resolvePath(path, workspace, t.restrict) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index 623d29b3..38ed763a 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -101,8 +101,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) return t.executeInSandbox(ctx, path, sandboxKey) } - // Host execution - resolved, err := resolvePathWithAllowed(path, t.workspace, t.restrict, t.allowedPrefixes) + // Host execution — use per-user workspace from context if available (managed mode) + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + resolved, err := resolvePathWithAllowed(path, workspace, t.restrict, t.allowedPrefixes) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go index 3795063c..8b7b7304 100644 --- a/internal/tools/filesystem_list.go +++ b/internal/tools/filesystem_list.go @@ -53,8 +53,12 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]interface{} return t.executeInSandbox(ctx, path, sandboxKey) } - // Host execution - resolved, err := resolvePath(path, t.workspace, t.restrict) + // Host execution — use per-user workspace from context if available (managed mode) + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + resolved, err := resolvePath(path, workspace, t.restrict) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index 96d97e3c..0674c4c8 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -91,8 +91,12 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{} return t.executeInSandbox(ctx, path, content, sandboxKey) } - // Host execution - resolved, err := resolvePath(path, t.workspace, t.restrict) + // Host execution — use per-user workspace from context if available (managed mode) + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + resolved, err := resolvePath(path, workspace, t.restrict) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 4eaf32bd..985b20b8 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -208,7 +208,11 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *Re } } - cwd := t.workingDir + // Use per-user workspace from context if available (managed mode), fallback to struct field + cwd := ToolWorkspaceFromCtx(ctx) + if cwd == "" { + cwd = t.workingDir + } if wd, _ := args["working_dir"].(string); wd != "" { if t.restrict { resolved, err := resolvePath(wd, t.workingDir, true) diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 1f78840a..5a02abe7 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -20,6 +20,9 @@ const ( EventHeartbeat = "heartbeat" EventTalkMode = "talk.mode" + // Agent summoning events (predefined agent setup via LLM). + EventAgentSummoning = "agent.summoning" + // Cache invalidation events (internal, not forwarded to WS clients). EventCacheInvalidate = "cache.invalidate" ) diff --git a/ui/web/package.json b/ui/web/package.json index 199af9ee..24736a11 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -14,6 +14,7 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.34.3", "lucide-react": "^0.468.0", "radix-ui": "^1.4.3", "react": "^19.0.0", diff --git a/ui/web/src/lib/slug.ts b/ui/web/src/lib/slug.ts index 02c44c4d..afb273fa 100644 --- a/ui/web/src/lib/slug.ts +++ b/ui/web/src/lib/slug.ts @@ -6,7 +6,7 @@ export function slugify(input: string): string { .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, ""); + .replace(/^-+/g, ""); } /** diff --git a/ui/web/src/pages/agents/agent-card.tsx b/ui/web/src/pages/agents/agent-card.tsx index 402fd4e0..308a741d 100644 --- a/ui/web/src/pages/agents/agent-card.tsx +++ b/ui/web/src/pages/agents/agent-card.tsx @@ -38,9 +38,15 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
{agent.agent_key}
)} - - {agent.status} - + {agent.status === "summoning" ? ( + + Summoning... + + ) : ( + + {agent.status} + + )} {/* Model info */} diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index b41edbf1..824c9552 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -9,6 +9,7 @@ import { 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 { Select, SelectContent, @@ -21,6 +22,7 @@ import type { AgentData } from "@/types/agent"; import { slugify, isValidSlug } from "@/lib/slug"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; +import { AGENT_PRESETS } from "./agent-presets"; interface AgentCreateDialogProps { open: boolean; @@ -35,6 +37,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); const [agentType, setAgentType] = useState<"open" | "predefined">("open"); + const [description, setDescription] = useState(""); const [loading, setLoading] = useState(false); const enabledProviders = providers.filter((p) => p.enabled); @@ -56,7 +59,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD provider: provider.trim(), model: model.trim(), agent_type: agentType, - status: "active", + other_config: description.trim() ? { description: description.trim() } : undefined, }); onOpenChange(false); setAgentKey(""); @@ -64,6 +67,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD setProvider(""); setModel(""); setAgentType("open"); + setDescription(""); } catch { // error handled upstream } finally { @@ -78,7 +82,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD return ( - + Create Agent @@ -148,6 +152,34 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD + + {agentType === "predefined" && ( +
+ +
+ {AGENT_PRESETS.map((preset) => ( + + ))} +
+