1package agent
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"encoding/json"
  7	"errors"
  8	"fmt"
  9	"net/http"
 10	"os"
 11	"time"
 12
 13	"charm.land/fantasy"
 14
 15	"github.com/charmbracelet/crush/internal/agent/prompt"
 16	"github.com/charmbracelet/crush/internal/agent/tools"
 17	"github.com/charmbracelet/crush/internal/config"
 18	"github.com/charmbracelet/crush/internal/permission"
 19)
 20
 21//go:embed templates/fetch.md
 22var fetchToolDescription []byte
 23
 24//go:embed templates/fetch_prompt.md.tpl
 25var fetchPromptTmpl []byte
 26
 27func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
 28	_, ok := c.cfg.Agents[config.AgentFetch]
 29	if !ok {
 30		return nil, errors.New("fetch agent not configured")
 31	}
 32
 33	if client == nil {
 34		client = &http.Client{
 35			Timeout: 30 * time.Second,
 36			Transport: &http.Transport{
 37				MaxIdleConns:        100,
 38				MaxIdleConnsPerHost: 10,
 39				IdleConnTimeout:     90 * time.Second,
 40			},
 41		}
 42	}
 43
 44	return fantasy.NewAgentTool(
 45		tools.FetchToolName,
 46		string(fetchToolDescription),
 47		func(ctx context.Context, params tools.FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 48			if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
 49				return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 50			}
 51
 52			if params.URL == "" {
 53				return fantasy.NewTextErrorResponse("url is required"), nil
 54			}
 55
 56			if params.Prompt == "" {
 57				return fantasy.NewTextErrorResponse("prompt is required"), nil
 58			}
 59
 60			sessionID := tools.GetSessionFromContext(ctx)
 61			if sessionID == "" {
 62				return fantasy.ToolResponse{}, errors.New("session id missing from context")
 63			}
 64
 65			agentMessageID := tools.GetMessageFromContext(ctx)
 66			if agentMessageID == "" {
 67				return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
 68			}
 69
 70			p := c.permissions.Request(
 71				permission.CreatePermissionRequest{
 72					SessionID:   sessionID,
 73					Path:        c.cfg.WorkingDir(),
 74					ToolCallID:  call.ID,
 75					ToolName:    tools.FetchToolName,
 76					Action:      "fetch",
 77					Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL),
 78					Params:      tools.FetchPermissionsParams(params),
 79				},
 80			)
 81
 82			if !p {
 83				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 84			}
 85
 86			content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
 87			if err != nil {
 88				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
 89			}
 90
 91			tmpDir, err := os.MkdirTemp("", "crush-fetch-*")
 92			if err != nil {
 93				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
 94			}
 95			defer os.RemoveAll(tmpDir)
 96
 97			hasLargeContent := len(content) > tools.LargeContentThreshold
 98			var fullPrompt string
 99
100			if hasLargeContent {
101				tempFile, err := os.CreateTemp(tmpDir, "page-*.md")
102				if err != nil {
103					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
104				}
105				tempFilePath := tempFile.Name()
106
107				if _, err := tempFile.WriteString(content); err != nil {
108					tempFile.Close()
109					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
110				}
111				tempFile.Close()
112
113				fullPrompt = fmt.Sprintf("%s\n\nThe web page from %s has been saved to: %s\n\nUse the view and grep tools to analyze this file and extract the requested information.", params.Prompt, params.URL, tempFilePath)
114			} else {
115				fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
116			}
117
118			promptOpts := []prompt.Option{
119				prompt.WithWorkingDir(tmpDir),
120			}
121
122			promptTemplate, err := prompt.NewPrompt("fetch", string(fetchPromptTmpl), promptOpts...)
123			if err != nil {
124				return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
125			}
126
127			_, small, err := c.buildAgentModels(ctx)
128			if err != nil {
129				return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
130			}
131
132			systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
133			if err != nil {
134				return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
135			}
136
137			smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
138			if !ok {
139				return fantasy.ToolResponse{}, errors.New("small model provider not configured")
140			}
141
142			webFetchTool := tools.NewWebFetchTool(tmpDir, client)
143			fetchTools := []fantasy.AgentTool{
144				webFetchTool,
145				tools.NewGlobTool(tmpDir),
146				tools.NewGrepTool(tmpDir),
147				tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
148			}
149
150			agent := NewSessionAgent(SessionAgentOptions{
151				LargeModel:           small, // Use small model for both (fetch doesn't need large)
152				SmallModel:           small,
153				SystemPromptPrefix:   smallProviderCfg.SystemPromptPrefix,
154				SystemPrompt:         systemPrompt,
155				DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
156				IsYolo:               c.permissions.SkipRequests(),
157				Sessions:             c.sessions,
158				Messages:             c.messages,
159				Tools:                fetchTools,
160			})
161
162			agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID)
163			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "Fetch Analysis")
164			if err != nil {
165				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
166			}
167
168			c.permissions.AutoApproveSession(session.ID)
169
170			// Use small model for web content analysis (faster and cheaper)
171			maxTokens := small.CatwalkCfg.DefaultMaxTokens
172			if small.ModelCfg.MaxTokens != 0 {
173				maxTokens = small.ModelCfg.MaxTokens
174			}
175
176			result, err := agent.Run(ctx, SessionAgentCall{
177				SessionID:        session.ID,
178				Prompt:           fullPrompt,
179				MaxOutputTokens:  maxTokens,
180				ProviderOptions:  getProviderOptions(small, smallProviderCfg),
181				Temperature:      small.ModelCfg.Temperature,
182				TopP:             small.ModelCfg.TopP,
183				TopK:             small.ModelCfg.TopK,
184				FrequencyPenalty: small.ModelCfg.FrequencyPenalty,
185				PresencePenalty:  small.ModelCfg.PresencePenalty,
186			})
187			if err != nil {
188				return fantasy.NewTextErrorResponse("error generating response"), nil
189			}
190
191			updatedSession, err := c.sessions.Get(ctx, session.ID)
192			if err != nil {
193				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
194			}
195			parentSession, err := c.sessions.Get(ctx, sessionID)
196			if err != nil {
197				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
198			}
199
200			parentSession.Cost += updatedSession.Cost
201
202			_, err = c.sessions.Save(ctx, parentSession)
203			if err != nil {
204				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
205			}
206
207			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
208		}), nil
209}