package http import ( "context" "log/slog" "regexp" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) // Summoning event type constants. const ( SummonEventStarted = "started" SummonEventFailed = "failed" SummonEventCompleted = "completed" SummonEventFileGenerated = "file_generated" ) // frontmatterKey is the special key used to store frontmatter in the parsed file map. const frontmatterKey = "__frontmatter__" // summoningFiles is the ordered list of context files the LLM should generate. // Only personality files — operational files (AGENTS.md, TOOLS.md) // are kept as fixed templates from bootstrap.SeedToStore(). // USER_PREDEFINED.md is optional — generated only when description mentions user context. var summoningFiles = []string{ bootstrap.SoulFile, bootstrap.IdentityFile, bootstrap.UserPredefinedFile, } // fileTagRe parses content from LLM output. var fileTagRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) // identityNameRe extracts the Name field from IDENTITY.md format: - **Name:** value var identityNameRe = regexp.MustCompile(`(?m)^-\s*\*\*Name:\*\*\s*(.+)$`) // frontmatterTagRe parses short expertise summary from LLM output. var frontmatterTagRe = 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, } } // singleCallTimeout is the deadline for the optimistic single LLM call. // If exceeded, we fall back to the 2-call approach with the remaining budget. const singleCallTimeout = 300 * time.Second // SummonAgent generates context files from a natural language description. // Meant to be called as a goroutine: go summoner.SummonAgent(...) // Tries a single LLM call first (all files at once). On timeout, falls back to // 2 sequential calls (SOUL.md → IDENTITY.md + USER_PREDEFINED.md). // On retry (resummon), skips files that were already generated (differ from template). // On success: stores generated files and sets agent status to "active". // On failure: keeps template files (already seeded) and sets status to store.AgentStatusSummonFailed. func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) { ctx, cancel := context.WithTimeout(context.Background(), 600*time.Second) defer cancel() s.ensureUserPredefined(ctx, agentID) s.emitEvent(agentID, SummonEventStarted, "", "") // Check which files already exist (from a previous partial run) existingMap := s.loadExistingFiles(ctx, agentID) // Skip if all files already generated if s.isGenerated(existingMap, bootstrap.SoulFile) && s.isGenerated(existingMap, bootstrap.IdentityFile) { slog.Info("summoning: all files already generated, skipping", "agent", agentID) s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") s.finishSummon(ctx, agentID, existingMap[bootstrap.IdentityFile], "", description) return } // === Optimistic single-call: generate all files at once === singleCtx, singleCancel := context.WithTimeout(ctx, singleCallTimeout) files, err := s.generateFiles(singleCtx, providerName, model, s.buildCreatePrompt(description)) singleCancel() if err == nil { slog.Info("summoning: single-call succeeded", "agent", agentID) s.storeFiles(ctx, agentID, files) s.finishSummon(ctx, agentID, files[bootstrap.IdentityFile], files[frontmatterKey], description) return } // Non-retryable error → fail immediately if !isRetryableError(err) { slog.Warn("summoning: single-call failed (non-retryable)", "agent", agentID, "error", err) s.emitEvent(agentID, SummonEventFailed, "", err.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } // === Fallback: 2-call approach === slog.Info("summoning: single-call timed out, falling back to 2-call", "agent", agentID, "error", err) // Refresh existing files (single-call didn't store anything on error) existingMap = s.loadExistingFiles(ctx, agentID) var soulContent string var frontmatter string // Step 1: Generate SOUL.md if s.isGenerated(existingMap, bootstrap.SoulFile) { soulContent = existingMap[bootstrap.SoulFile] slog.Info("summoning: SOUL.md already generated, skipping", "agent", agentID) s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") } else { soulFiles, soulErr := s.generateFiles(ctx, providerName, model, s.buildSoulPrompt(description)) if soulErr != nil { slog.Warn("summoning: SOUL.md generation failed", "agent", agentID, "error", soulErr) s.emitEvent(agentID, SummonEventFailed, "", soulErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } soulContent = soulFiles[bootstrap.SoulFile] frontmatter = soulFiles[frontmatterKey] if soulContent != "" { if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.SoulFile, soulContent); storeErr != nil { slog.Warn("summoning: failed to store SOUL.md", "agent", agentID, "error", storeErr) } else { s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") } } } // Step 2: Generate IDENTITY.md + USER_PREDEFINED.md using SOUL.md as context identityNeeded := !s.isGenerated(existingMap, bootstrap.IdentityFile) userPredNeeded := !s.isGenerated(existingMap, bootstrap.UserPredefinedFile) var identityContent string if !identityNeeded && !userPredNeeded { identityContent = existingMap[bootstrap.IdentityFile] slog.Info("summoning: IDENTITY.md + USER_PREDEFINED.md already generated, skipping", "agent", agentID) s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } else { idFiles, idErr := s.generateFiles(ctx, providerName, model, s.buildIdentityPrompt(description, soulContent)) if idErr != nil { slog.Warn("summoning: IDENTITY.md generation failed", "agent", agentID, "error", idErr) s.emitEvent(agentID, SummonEventFailed, "", idErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } identityContent = idFiles[bootstrap.IdentityFile] if frontmatter == "" { frontmatter = idFiles[frontmatterKey] } if identityContent != "" && identityNeeded { if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.IdentityFile, identityContent); storeErr != nil { slog.Warn("summoning: failed to store IDENTITY.md", "agent", agentID, "error", storeErr) } else { s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } } if upContent := idFiles[bootstrap.UserPredefinedFile]; upContent != "" && userPredNeeded { if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, upContent); storeErr != nil { slog.Warn("summoning: failed to store USER_PREDEFINED.md", "agent", agentID, "error", storeErr) } else { s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "") } } } s.finishSummon(ctx, agentID, identityContent, frontmatter, description) } // finishSummon saves agent metadata and marks the agent as active. func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, identityContent, frontmatter, description string) { updates := map[string]any{} if frontmatter == "" { frontmatter = truncateUTF8(description, 200) } if frontmatter != "" { updates["frontmatter"] = frontmatter } if name := extractIdentityName(identityContent); name != "" { updates["display_name"] = name } if len(updates) > 0 { if err := s.agents.Update(ctx, agentID, updates); err != nil { slog.Warn("summoning: failed to save agent metadata", "agent", agentID, "error", err) } } s.setAgentStatus(ctx, agentID, store.AgentStatusActive) s.emitEvent(agentID, SummonEventCompleted, "", "") slog.Info("summoning: completed", "agent", agentID) } // loadExistingFiles reads agent context files and returns them as a map. func (s *AgentSummoner) loadExistingFiles(ctx context.Context, agentID uuid.UUID) map[string]string { existing, _ := s.agents.GetAgentContextFiles(ctx, agentID) m := make(map[string]string, len(existing)) for _, f := range existing { m[f.FileName] = f.Content } return m }