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