mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-24 16:22:52 +00:00
- 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
219 lines
7.0 KiB
Go
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,
|
|
})
|
|
}
|