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}