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