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 string
 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		transport := http.DefaultTransport.(*http.Transport).Clone()
 56		transport.MaxIdleConns = 100
 57		transport.MaxIdleConnsPerHost = 10
 58		transport.IdleConnTimeout = 90 * time.Second
 59
 60		client = &http.Client{
 61			Timeout:   30 * time.Second,
 62			Transport: transport,
 63		}
 64	}
 65
 66	return fantasy.NewParallelAgentTool(
 67		tools.AgenticFetchToolName,
 68		agenticFetchToolDescription,
 69		func(ctx context.Context, params tools.AgenticFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 70			validationResult, err := validateAgenticFetchParams(ctx, params)
 71			if err != nil {
 72				return fantasy.NewTextErrorResponse(err.Error()), nil
 73			}
 74
 75			// Determine description based on mode.
 76			var description string
 77			if params.URL != "" {
 78				description = fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL)
 79			} else {
 80				description = "Search the web and analyze results"
 81			}
 82
 83			p, err := c.permissions.Request(
 84				ctx,
 85				permission.CreatePermissionRequest{
 86					SessionID:   validationResult.SessionID,
 87					Path:        c.cfg.WorkingDir(),
 88					ToolCallID:  call.ID,
 89					ToolName:    tools.AgenticFetchToolName,
 90					Action:      "fetch",
 91					Description: description,
 92					Params:      tools.AgenticFetchPermissionsParams(params),
 93				},
 94			)
 95			if err != nil {
 96				return fantasy.ToolResponse{}, err
 97			}
 98			if !p {
 99				return tools.NewPermissionDeniedResponse(), nil
100			}
101
102			tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
103			if err != nil {
104				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
105			}
106			defer os.RemoveAll(tmpDir)
107
108			var fullPrompt string
109
110			if params.URL != "" {
111				// URL mode: fetch the URL content first.
112				content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
113				if err != nil {
114					return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
115				}
116
117				hasLargeContent := len(content) > tools.LargeContentThreshold
118
119				if hasLargeContent {
120					tempFile, err := os.CreateTemp(tmpDir, "page-*.md")
121					if err != nil {
122						return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
123					}
124					tempFilePath := tempFile.Name()
125
126					if _, err := tempFile.WriteString(content); err != nil {
127						tempFile.Close()
128						return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
129					}
130					tempFile.Close()
131
132					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)
133				} else {
134					fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
135				}
136			} else {
137				// Search mode: let the sub-agent search and fetch as needed.
138				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)
139			}
140
141			promptOpts := []prompt.Option{
142				prompt.WithWorkingDir(tmpDir),
143			}
144
145			promptTemplate, err := prompt.NewPrompt("agentic_fetch", string(agenticFetchPromptTmpl), promptOpts...)
146			if err != nil {
147				return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
148			}
149
150			_, small, err := c.buildAgentModels(ctx, true)
151			if err != nil {
152				return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
153			}
154
155			systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg)
156			if err != nil {
157				return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
158			}
159
160			smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
161			if !ok {
162				return fantasy.ToolResponse{}, errors.New("small model provider not configured")
163			}
164
165			webFetchTool := tools.NewWebFetchTool(tmpDir, client)
166			webSearchTool := tools.NewWebSearchTool(client)
167			fetchTools := []fantasy.AgentTool{
168				webFetchTool,
169				webSearchTool,
170				tools.NewGlobTool(tmpDir),
171				tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
172				tools.NewSourcegraphTool(client),
173				tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, nil, tmpDir),
174			}
175
176			// Sub-agent tools run without hook interception. The top-level
177			// `agentic_fetch` call itself is already wrapped from the coder's
178			// side; firing hooks again for every inner tool call would run
179			// the user's hooks N times per delegated turn.
180
181			agent := NewSessionAgent(SessionAgentOptions{
182				LargeModel:           small, // Use small model for both (fetch doesn't need large)
183				SmallModel:           small,
184				SystemPromptPrefix:   smallProviderCfg.SystemPromptPrefix,
185				SystemPrompt:         systemPrompt,
186				DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
187				IsYolo:               c.permissions.SkipRequests(),
188				Sessions:             c.sessions,
189				Messages:             c.messages,
190				Tools:                fetchTools,
191			})
192
193			return c.runSubAgent(ctx, subAgentParams{
194				Agent:          agent,
195				SessionID:      validationResult.SessionID,
196				AgentMessageID: validationResult.AgentMessageID,
197				ToolCallID:     call.ID,
198				Prompt:         fullPrompt,
199				SessionTitle:   "Fetch Analysis",
200				SessionSetup: func(sessionID string) {
201					c.permissions.AutoApproveSession(sessionID)
202				},
203			})
204		},
205	), nil
206}