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 {
97 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
98 }
99
100 tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*")
101 if err != nil {
102 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
103 }
104 defer os.RemoveAll(tmpDir)
105
106 var fullPrompt string
107
108 if params.URL != "" {
109 // URL mode: fetch the URL content first.
110 content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
111 if err != nil {
112 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
113 }
114
115 hasLargeContent := len(content) > tools.LargeContentThreshold
116
117 if hasLargeContent {
118 tempFile, err := os.CreateTemp(tmpDir, "page-*.md")
119 if err != nil {
120 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
121 }
122 tempFilePath := tempFile.Name()
123
124 if _, err := tempFile.WriteString(content); err != nil {
125 tempFile.Close()
126 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
127 }
128 tempFile.Close()
129
130 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)
131 } else {
132 fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
133 }
134 } else {
135 // Search mode: let the sub-agent search and fetch as needed.
136 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)
137 }
138
139 promptOpts := []prompt.Option{
140 prompt.WithWorkingDir(tmpDir),
141 }
142
143 promptTemplate, err := prompt.NewPrompt("agentic_fetch", string(agenticFetchPromptTmpl), promptOpts...)
144 if err != nil {
145 return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
146 }
147
148 _, small, err := c.buildAgentModels(ctx, true)
149 if err != nil {
150 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
151 }
152
153 systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
154 if err != nil {
155 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
156 }
157
158 smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
159 if !ok {
160 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
161 }
162
163 webFetchTool := tools.NewWebFetchTool(tmpDir, client)
164 webSearchTool := tools.NewWebSearchTool(client)
165 fetchTools := []fantasy.AgentTool{
166 webFetchTool,
167 webSearchTool,
168 tools.NewGlobTool(tmpDir),
169 tools.NewGrepTool(tmpDir),
170 tools.NewSourcegraphTool(client),
171 tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
172 }
173
174 agent := NewSessionAgent(SessionAgentOptions{
175 LargeModel: small, // Use small model for both (fetch doesn't need large)
176 SmallModel: small,
177 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
178 SystemPrompt: systemPrompt,
179 DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
180 IsYolo: c.permissions.SkipRequests(),
181 Sessions: c.sessions,
182 Messages: c.messages,
183 Tools: fetchTools,
184 })
185
186 agentToolSessionID := c.sessions.CreateAgentToolSessionID(validationResult.AgentMessageID, call.ID)
187 session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, validationResult.SessionID, "Fetch Analysis")
188 if err != nil {
189 return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
190 }
191
192 c.permissions.AutoApproveSession(session.ID)
193
194 // Use small model for web content analysis (faster and cheaper)
195 maxTokens := small.CatwalkCfg.DefaultMaxTokens
196 if small.ModelCfg.MaxTokens != 0 {
197 maxTokens = small.ModelCfg.MaxTokens
198 }
199
200 result, err := agent.Run(ctx, SessionAgentCall{
201 SessionID: session.ID,
202 Prompt: fullPrompt,
203 MaxOutputTokens: maxTokens,
204 ProviderOptions: getProviderOptions(small, smallProviderCfg),
205 Temperature: small.ModelCfg.Temperature,
206 TopP: small.ModelCfg.TopP,
207 TopK: small.ModelCfg.TopK,
208 FrequencyPenalty: small.ModelCfg.FrequencyPenalty,
209 PresencePenalty: small.ModelCfg.PresencePenalty,
210 })
211 if err != nil {
212 return fantasy.NewTextErrorResponse("error generating response"), nil
213 }
214
215 updatedSession, err := c.sessions.Get(ctx, session.ID)
216 if err != nil {
217 return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
218 }
219 parentSession, err := c.sessions.Get(ctx, validationResult.SessionID)
220 if err != nil {
221 return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
222 }
223
224 parentSession.Cost += updatedSession.Cost
225
226 _, err = c.sessions.Save(ctx, parentSession)
227 if err != nil {
228 return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
229 }
230
231 return fantasy.NewTextResponse(result.Response.Content.Text()), nil
232 }), nil
233}