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