Files
goclaw/internal/http/summoner_regenerate.go
T
viettranx 28fab9507a feat(storage): add lazy folder loading, SSE size endpoint, and enhanced file viewer
- Backend: depth-limited WalkDir (max 3 levels default) with on-demand subtree loading
- Backend: new GET /v1/storage/size SSE endpoint with 60min in-memory cache
- Backend: raw binary file serving (?raw=true) with MIME detection and download support
- Frontend: lazy tree expansion with loading spinners for deep folders
- Frontend: streaming size display with cache info tooltip
- Frontend: image viewer (blob URL), unsupported file UI, download button, colored size badges
- Frontend: file-type icons for 13 categories (md, json, yaml, images, video, etc.)
- Fix sidebar connection status text overflow on collapse
- Apply go fix modernization (interface{} → any) across http handlers
2026-03-14 18:13:52 +07:00

219 lines
7.0 KiB
Go

package http
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"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"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// 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(), 300*time.Second)
defer cancel()
s.ensureUserPredefined(ctx, agentID)
s.emitEvent(agentID, SummonEventStarted, "", "")
// 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, SummonEventFailed, "", err.Error())
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
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, 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
}
s.storeFiles(ctx, agentID, files)
// Update frontmatter + display_name if IDENTITY.md was regenerated
updates := map[string]any{}
if fm, ok := files[frontmatterKey]; ok && fm != "" {
updates["frontmatter"] = fm
}
if name := extractIdentityName(files[bootstrap.IdentityFile]); 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: regeneration completed", "agent", agentID, "files", len(files))
}
// isRetryableError returns true for timeout and context-cancellation errors
// that warrant falling back to the 2-call approach.
func isRetryableError(err error) bool {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return false
}
// 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)
if err != nil {
return nil, fmt.Errorf("resolve provider: %w", err)
}
// Use a unique session key so CLI-based providers get an isolated workdir
// (prevents polluting/reading CLAUDE.md from active chat sessions).
summonSessionKey := "summon-" + uuid.New().String()
slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt))
resp, err := provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{Role: "system", Content: "You are a file generator. Output ONLY the requested XML-tagged files. No extra commentary."},
{Role: "user", Content: prompt},
},
Model: model,
Options: map[string]any{
"max_tokens": 8192,
"temperature": 0.7,
providers.OptSessionKey: summonSessionKey,
providers.OptDisableTools: true,
},
})
if err != nil {
return nil, fmt.Errorf("%s: %w", providerName, err)
}
slog.Info("summoning: raw LLM response", "provider", providerName, "length", len(resp.Content),
"preview_start", truncateUTF8(resp.Content, 500),
"preview_end", truncateUTF8(suffixString(resp.Content, 500), 500))
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, SummonEventFileGenerated, 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
}
// ensureUserPredefined seeds USER_PREDEFINED.md template if it doesn't exist yet.
// Backfills agents created before this feature was added.
func (s *AgentSummoner) ensureUserPredefined(ctx context.Context, agentID uuid.UUID) {
existing, err := s.agents.GetAgentContextFiles(ctx, agentID)
if err != nil {
return
}
for _, f := range existing {
if f.FileName == bootstrap.UserPredefinedFile {
return // already exists
}
}
if tpl, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile); err == nil {
_ = s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, tpl)
}
}
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]any{
"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,
})
}