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