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