diff --git a/internal/http/summoner.go b/internal/http/summoner.go
index 60f8d044..2978e147 100644
--- a/internal/http/summoner.go
+++ b/internal/http/summoner.go
@@ -66,38 +66,106 @@ func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry,
// SummonAgent generates context files from a natural language description.
// Meant to be called as a goroutine: go summoner.SummonAgent(...)
+// Generates SOUL.md first, then IDENTITY.md + USER_PREDEFINED.md using SOUL.md as context,
+// emitting progress events after each file so the UI can show incremental progress.
+// On retry, 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(), 300*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 600*time.Second)
defer cancel()
s.ensureUserPredefined(ctx, agentID)
s.emitEvent(agentID, SummonEventStarted, "", "")
- 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, SummonEventFailed, "", err.Error())
- // Use fresh context — the original may have timed out, but we still need to update status.
- s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
- return
+ // Check which files already exist (from a previous partial run)
+ existing, _ := s.agents.GetAgentContextFiles(ctx, agentID)
+ existingMap := make(map[string]string, len(existing))
+ for _, f := range existing {
+ existingMap[f.FileName] = f.Content
}
- s.storeFiles(ctx, agentID, files)
+ var soulContent string
+ var frontmatter string
- // Save frontmatter + display_name extracted from IDENTITY.md
+ // Step 1: Generate SOUL.md (skip if already generated, i.e. differs from template)
+ 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, err := s.generateFiles(ctx, providerName, model, s.buildSoulPrompt(description))
+ if err != nil {
+ slog.Warn("summoning: SOUL.md generation failed", "agent", agentID, "error", err)
+ s.emitEvent(agentID, SummonEventFailed, "", err.Error())
+ s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
+ return
+ }
+
+ soulContent = soulFiles[bootstrap.SoulFile]
+ frontmatter = soulFiles[frontmatterKey]
+ if soulContent != "" {
+ if err := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.SoulFile, soulContent); err != nil {
+ slog.Warn("summoning: failed to store SOUL.md", "agent", agentID, "error", err)
+ } 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 {
+ // Both already generated from a previous run
+ 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, err := s.generateFiles(ctx, providerName, model, s.buildIdentityPrompt(description, soulContent))
+ if err != nil {
+ slog.Warn("summoning: IDENTITY.md generation failed", "agent", agentID, "error", err)
+ s.emitEvent(agentID, SummonEventFailed, "", err.Error())
+ s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
+ return
+ }
+
+ identityContent = idFiles[bootstrap.IdentityFile]
+ if frontmatter == "" {
+ frontmatter = idFiles[frontmatterKey]
+ }
+
+ // Store IDENTITY.md
+ if identityContent != "" && identityNeeded {
+ if err := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.IdentityFile, identityContent); err != nil {
+ slog.Warn("summoning: failed to store IDENTITY.md", "agent", agentID, "error", err)
+ } else {
+ s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
+ }
+ }
+
+ // Store USER_PREDEFINED.md (optional — only if LLM generated it)
+ if upContent := idFiles[bootstrap.UserPredefinedFile]; upContent != "" && userPredNeeded {
+ if err := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, upContent); err != nil {
+ slog.Warn("summoning: failed to store USER_PREDEFINED.md", "agent", agentID, "error", err)
+ } else {
+ s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "")
+ }
+ }
+ }
+
+ // Save frontmatter + display_name
updates := map[string]any{}
- fm := files[frontmatterKey]
- if fm == "" {
- fm = truncateUTF8(description, 200)
+ if frontmatter == "" {
+ frontmatter = truncateUTF8(description, 200)
}
- if fm != "" {
- updates["frontmatter"] = fm
+ if frontmatter != "" {
+ updates["frontmatter"] = frontmatter
}
- if name := extractIdentityName(files[bootstrap.IdentityFile]); name != "" {
+ if name := extractIdentityName(identityContent); name != "" {
updates["display_name"] = name
}
if len(updates) > 0 {
@@ -109,7 +177,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
s.setAgentStatus(ctx, agentID, store.AgentStatusActive)
s.emitEvent(agentID, SummonEventCompleted, "", "")
- slog.Info("summoning: completed", "agent", agentID, "files", len(files))
+ slog.Info("summoning: completed", "agent", agentID)
}
// RegenerateAgent updates context files based on an edit prompt.
@@ -165,6 +233,19 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files))
}
+// isGenerated checks if a context file has been generated (differs from the default template).
+func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName string) bool {
+ content, ok := existingMap[fileName]
+ if !ok || content == "" {
+ return false
+ }
+ template, err := bootstrap.ReadTemplate(fileName)
+ if err != nil {
+ return false
+ }
+ return content != template
+}
+
// 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)
@@ -172,6 +253,8 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model,
return nil, fmt.Errorf("resolve provider: %w", err)
}
+ slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt))
+
resp, err := provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{Role: "user", Content: prompt},
@@ -183,9 +266,11 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model,
},
})
if err != nil {
- return nil, fmt.Errorf("LLM call: %w", err)
+ return nil, fmt.Errorf("%s: %w", providerName, err)
}
+ slog.Info("summoning: LLM responded", "provider", providerName, "content_len", len(resp.Content))
+
files := parseFileResponse(resp.Content)
if len(files) == 0 {
return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content))
@@ -273,40 +358,20 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
})
}
-// buildCreatePrompt constructs the prompt for initial SOUL.md + IDENTITY.md generation.
-// Only personality files are LLM-generated; operational files stay as fixed templates.
-// USER_PREDEFINED.md is optionally generated when description mentions user context.
-func (s *AgentSummoner) buildCreatePrompt(description string) string {
+// buildSoulPrompt constructs the prompt for SOUL.md generation.
+func (s *AgentSummoner) buildSoulPrompt(description string) string {
var sb strings.Builder
- sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the required files.\n\n")
+ sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the SOUL.md file that defines its personality.\n\n")
fmt.Fprintf(&sb, "\n%s\n\n\n", description)
- // Load templates as reference
soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile)
if err != nil {
slog.Warn("summoning: failed to read SOUL.md template", "error", err)
}
- identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile)
- if err != nil {
- slog.Warn("summoning: failed to read IDENTITY.md template", "error", err)
- }
- userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile)
- if err != nil {
- slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err)
- }
-
- sb.WriteString("\n")
if soulTemplate != "" {
- fmt.Fprintf(&sb, "\n%s\n\n", soulTemplate)
+ fmt.Fprintf(&sb, "\n%s\n\n\n", soulTemplate)
}
- if identityTemplate != "" {
- fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate)
- }
- if userPredefinedTemplate != "" {
- fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate)
- }
- sb.WriteString("\n\n")
sb.WriteString(`IMPORTANT RULES:
@@ -321,24 +386,9 @@ func (s *AgentSummoner) buildCreatePrompt(description string) string {
- "## Continuity" — keep as-is (just translate if needed).
- KEEP the exact English headings. Do NOT add the agent's name into Core Truths or Boundaries.
-3. IDENTITY.md rules:
- - KEEP the exact English heading: "# IDENTITY.md - Who Am I?"
- - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description.
- - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description.
- - REMOVE all template placeholder/instruction text (the italic hints in parentheses).
- - Leave Avatar blank.
- - Keep the footer note section as-is.
+3. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery.
-4. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery.
-
-5. USER_PREDEFINED.md (OPTIONAL — only generate if relevant):
- - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context.
- - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in SOUL.md or IDENTITY.md.
- - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely.
- - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override.
- - Keep headings in English. Write content in the same language as the description.
-
-Output format — generate in this EXACT order:
+Output format:
(short expertise summary here)
@@ -346,7 +396,62 @@ Output format — generate in this EXACT order:
(content here)
-
+`)
+
+ return sb.String()
+}
+
+// buildIdentityPrompt constructs the prompt for IDENTITY.md + USER_PREDEFINED.md generation,
+// using the already-generated SOUL.md as context for consistency.
+func (s *AgentSummoner) buildIdentityPrompt(description, soulContent string) string {
+ var sb strings.Builder
+ sb.WriteString("You are setting up a new AI assistant. The SOUL.md (personality) has already been generated. Now generate IDENTITY.md and optionally USER_PREDEFINED.md based on the description and soul.\n\n")
+
+ fmt.Fprintf(&sb, "\n%s\n\n\n", description)
+
+ if soulContent != "" {
+ fmt.Fprintf(&sb, "\n%s\n\n\n", soulContent)
+ }
+
+ identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile)
+ if err != nil {
+ slog.Warn("summoning: failed to read IDENTITY.md template", "error", err)
+ }
+ userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile)
+ if err != nil {
+ slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err)
+ }
+
+ sb.WriteString("\n")
+ if identityTemplate != "" {
+ fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate)
+ }
+ if userPredefinedTemplate != "" {
+ fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate)
+ }
+ sb.WriteString("\n\n")
+
+ sb.WriteString(`IMPORTANT RULES:
+
+1. Language: Write ALL content in the SAME LANGUAGE as the . Keep headings in English.
+
+2. IDENTITY.md rules:
+ - KEEP the exact English heading: "# IDENTITY.md - Who Am I?"
+ - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description and soul.
+ - The Name, Creature, and Vibe should MATCH the personality defined in the soul.
+ - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description.
+ - REMOVE all template placeholder/instruction text (the italic hints in parentheses).
+ - Leave Avatar blank.
+ - Keep the footer note section as-is.
+
+3. USER_PREDEFINED.md (OPTIONAL — only generate if relevant):
+ - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context.
+ - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in IDENTITY.md.
+ - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely.
+ - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override.
+ - Keep headings in English. Write content in the same language as the description.
+
+Output format:
(content here)
diff --git a/internal/providers/anthropic.go b/internal/providers/anthropic.go
index ed02e371..c3c6fff2 100644
--- a/internal/providers/anthropic.go
+++ b/internal/providers/anthropic.go
@@ -32,7 +32,7 @@ func NewAnthropicProvider(apiKey string, opts ...AnthropicOption) *AnthropicProv
apiKey: apiKey,
baseURL: anthropicAPIBase,
defaultModel: defaultClaudeModel,
- client: &http.Client{Timeout: 120 * time.Second},
+ client: &http.Client{Timeout: 300 * time.Second},
retryConfig: DefaultRetryConfig(),
}
for _, o := range opts {
diff --git a/internal/providers/openai.go b/internal/providers/openai.go
index ed51c8ff..82343f87 100644
--- a/internal/providers/openai.go
+++ b/internal/providers/openai.go
@@ -36,7 +36,7 @@ func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvid
apiBase: apiBase,
chatPath: "/chat/completions",
defaultModel: defaultModel,
- client: &http.Client{Timeout: 120 * time.Second},
+ client: &http.Client{Timeout: 300 * time.Second},
retryConfig: DefaultRetryConfig(),
}
}