agentic_fetch_tool.go

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