From 3f060d18814b16060be4fcdd2f5eaff7a868e0c5 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 30 Oct 2025 15:25:17 +0100 Subject: [PATCH] chore: add back old fetch --- go.mod | 2 +- internal/agent/agent_tool.go | 4 - .../{fetch_tool.go => agentic_fetch_tool.go} | 27 +-- internal/agent/coordinator.go | 8 +- .../templates/{fetch.md => agentic_fetch.md} | 19 +- ...mpt.md.tpl => agentic_fetch_prompt.md.tpl} | 0 internal/agent/tools/fetch.go | 193 ++++++++++++++++++ internal/agent/tools/fetch.md | 45 ++++ internal/agent/tools/fetch_helpers.go | 26 +++ internal/agent/tools/fetch_types.go | 26 ++- internal/agent/tools/view.go | 7 +- internal/agent/tools/web_fetch.go | 5 - internal/config/config.go | 1 + internal/tui/components/chat/chat.go | 4 +- .../tui/components/chat/messages/renderer.go | 56 ++++- internal/tui/components/chat/messages/tool.go | 38 ++++ .../dialogs/permissions/permissions.go | 21 ++ 17 files changed, 435 insertions(+), 47 deletions(-) rename internal/agent/{fetch_tool.go => agentic_fetch_tool.go} (88%) rename internal/agent/templates/{fetch.md => agentic_fetch.md} (65%) rename internal/agent/templates/{fetch_prompt.md.tpl => agentic_fetch_prompt.md.tpl} (100%) create mode 100644 internal/agent/tools/fetch.go create mode 100644 internal/agent/tools/fetch.md diff --git a/go.mod b/go.mod index b70c2dfc95387dcf70ea39bf72a4f64e26cd73cc..378e0486af06e8d46368e1e881b8f2f4dc35f243 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( charm.land/fantasy v0.1.4 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 + github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 @@ -56,7 +57,6 @@ require ( cloud.google.com/go/compute/metadata v0.8.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/aws/aws-sdk-go-v2 v1.39.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 3e93181325091a1a91cd8a3e661194f11d38ee3f..b937c20dd510eb9dff52bd348bf7baf21aafcf8c 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -3,7 +3,6 @@ package agent import ( "context" _ "embed" - "encoding/json" "errors" "fmt" @@ -43,9 +42,6 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) AgentToolName, string(agentToolDescription), func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { - return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil - } if params.Prompt == "" { return fantasy.NewTextErrorResponse("prompt is required"), nil } diff --git a/internal/agent/fetch_tool.go b/internal/agent/agentic_fetch_tool.go similarity index 88% rename from internal/agent/fetch_tool.go rename to internal/agent/agentic_fetch_tool.go index ada9d375f6c610811772961b54556bfcd034ca9e..102ee8101a1afa08bc08bda39821c2d53eb91b71 100644 --- a/internal/agent/fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -3,7 +3,6 @@ package agent import ( "context" _ "embed" - "encoding/json" "errors" "fmt" "net/http" @@ -17,13 +16,13 @@ import ( "github.com/charmbracelet/crush/internal/permission" ) -//go:embed templates/fetch.md -var fetchToolDescription []byte +//go:embed templates/agentic_fetch.md +var agenticFetchToolDescription []byte -//go:embed templates/fetch_prompt.md.tpl -var fetchPromptTmpl []byte +//go:embed templates/agentic_fetch_prompt.md.tpl +var agenticFetchPromptTmpl []byte -func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) { +func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) { if client == nil { client = &http.Client{ Timeout: 30 * time.Second, @@ -36,13 +35,9 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy } return fantasy.NewAgentTool( - tools.FetchToolName, - string(fetchToolDescription), - func(ctx context.Context, params tools.FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { - return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil - } - + tools.AgenticFetchToolName, + string(agenticFetchToolDescription), + func(ctx context.Context, params tools.AgenticFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.URL == "" { return fantasy.NewTextErrorResponse("url is required"), nil } @@ -66,10 +61,10 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy SessionID: sessionID, Path: c.cfg.WorkingDir(), ToolCallID: call.ID, - ToolName: tools.FetchToolName, + ToolName: tools.AgenticFetchToolName, Action: "fetch", Description: fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL), - Params: tools.FetchPermissionsParams(params), + Params: tools.AgenticFetchPermissionsParams(params), }, ) @@ -113,7 +108,7 @@ func (c *coordinator) fetchTool(_ context.Context, client *http.Client) (fantasy prompt.WithWorkingDir(tmpDir), } - promptTemplate, err := prompt.NewPrompt("fetch", string(fetchPromptTmpl), promptOpts...) + promptTemplate, err := prompt.NewPrompt("agentic_fetch", string(agenticFetchPromptTmpl), promptOpts...) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err) } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 6c0e2752a56cc90a9bdf24d6c22cbe9eb0a26d48..367a7e67a2ea7fdf732984aa8d5774bf04752a8a 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -321,12 +321,12 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan allTools = append(allTools, agentTool) } - if slices.Contains(agent.AllowedTools, tools.FetchToolName) { - fetchTool, err := c.fetchTool(ctx, nil) + if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) { + agenticFetchTool, err := c.agenticFetchTool(ctx, nil) if err != nil { return nil, err } - allTools = append(allTools, fetchTool) + allTools = append(allTools, agenticFetchTool) } allTools = append(allTools, @@ -334,6 +334,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), + tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewGlobTool(c.cfg.WorkingDir()), tools.NewGrepTool(c.cfg.WorkingDir()), tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), @@ -663,7 +664,6 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con } } - // TODO: make sure we have apiKey, _ := c.cfg.Resolve(providerCfg.APIKey) baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL) diff --git a/internal/agent/templates/fetch.md b/internal/agent/templates/agentic_fetch.md similarity index 65% rename from internal/agent/templates/fetch.md rename to internal/agent/templates/agentic_fetch.md index 97c764157878b8f2b058ff7691f7c21e39a8f969..828212907fb9b0894531c096afa9ed73e3cd8824 100644 --- a/internal/agent/templates/fetch.md +++ b/internal/agent/templates/agentic_fetch.md @@ -1,4 +1,18 @@ -Fetches content from a specified URL and processes it using an AI model. +Fetches content from a specified URL and processes it using an AI model to extract information or answer questions. + + +Use this tool when you need to: +- Extract specific information from a webpage (e.g., "get pricing info") +- Answer questions about web content (e.g., "what does this article say about X?") +- Summarize or analyze web pages +- Find specific data within large pages +- Interpret or process web content with AI + +DO NOT use this tool when: +- You just need raw content without analysis (use fetch instead - faster and cheaper) +- You want direct access to API responses or JSON (use fetch instead) +- You don't need the content processed or interpreted (use fetch instead) + - Takes a URL and a prompt as input @@ -18,6 +32,7 @@ Fetches content from a specified URL and processes it using an AI model. - Results may be summarized if the content is very large - For very large pages, the content will be saved to a temporary file and the agent will have access to grep/view tools to analyze it - When a URL redirects to a different host, the tool will inform you and provide the redirect URL. You should then make a new fetch request with the redirect URL to fetch the content. +- This tool uses AI processing and costs more tokens than the simple fetch tool @@ -25,10 +40,12 @@ Fetches content from a specified URL and processes it using an AI model. - Only supports HTTP and HTTPS protocols - Cannot handle authentication or cookies - Some websites may block automated requests +- Uses additional tokens for AI processing - Be specific in your prompt about what information you want to extract - For complex pages, ask the agent to focus on specific sections - The agent has access to grep and view tools when analyzing large pages +- If you just need raw content, use the fetch tool instead to save tokens diff --git a/internal/agent/templates/fetch_prompt.md.tpl b/internal/agent/templates/agentic_fetch_prompt.md.tpl similarity index 100% rename from internal/agent/templates/fetch_prompt.md.tpl rename to internal/agent/templates/agentic_fetch_prompt.md.tpl diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..dd250398190fff554a62695912bd0bc7ed76a600 --- /dev/null +++ b/internal/agent/tools/fetch.go @@ -0,0 +1,193 @@ +package tools + +import ( + "context" + _ "embed" + "fmt" + "io" + "net/http" + "strings" + "time" + "unicode/utf8" + + "charm.land/fantasy" + md "github.com/JohannesKaufmann/html-to-markdown" + "github.com/PuerkitoBio/goquery" + "github.com/charmbracelet/crush/internal/permission" +) + +type fetchTool struct { + client *http.Client + permissions permission.Service + workingDir string +} + +const FetchToolName = "fetch" + +//go:embed fetch.md +var fetchDescription []byte + +func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool { + if client == nil { + client = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, + } + } + + return fantasy.NewAgentTool( + FetchToolName, + string(fetchDescription), + func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if params.URL == "" { + return fantasy.NewTextErrorResponse("URL parameter is required"), nil + } + + format := strings.ToLower(params.Format) + if format != "text" && format != "markdown" && format != "html" { + return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil + } + + if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") { + return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil + } + + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + } + + p := permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: workingDir, + ToolCallID: call.ID, + ToolName: FetchToolName, + Action: "fetch", + Description: fmt.Sprintf("Fetch content from URL: %s", params.URL), + Params: FetchPermissionsParams(params), + }, + ) + + if !p { + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied + } + + // Handle timeout with context + requestCtx := ctx + if params.Timeout > 0 { + maxTimeout := 120 // 2 minutes + if params.Timeout > maxTimeout { + params.Timeout = maxTimeout + } + var cancel context.CancelFunc + requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second) + defer cancel() + } + + req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil) + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "crush/1.0") + + resp, err := client.Do(req) + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil + } + + maxSize := int64(5 * 1024 * 1024) // 5MB + body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize)) + if err != nil { + return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil + } + + content := string(body) + + isValidUt8 := utf8.ValidString(content) + if !isValidUt8 { + return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil + } + contentType := resp.Header.Get("Content-Type") + + switch format { + case "text": + if strings.Contains(contentType, "text/html") { + text, err := extractTextFromHTML(content) + if err != nil { + return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil + } + content = text + } + + case "markdown": + if strings.Contains(contentType, "text/html") { + markdown, err := convertHTMLToMarkdown(content) + if err != nil { + return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil + } + content = markdown + } + + content = "```\n" + content + "\n```" + + case "html": + // return only the body of the HTML document + if strings.Contains(contentType, "text/html") { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(content)) + if err != nil { + return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil + } + body, err := doc.Find("body").Html() + if err != nil { + return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil + } + if body == "" { + return fantasy.NewTextErrorResponse("No body content found in HTML"), nil + } + content = "\n\n" + body + "\n\n" + } + } + // calculate byte size of content + contentSize := int64(len(content)) + if contentSize > MaxReadSize { + content = content[:MaxReadSize] + content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize) + } + + return fantasy.NewTextResponse(content), nil + }) +} + +func extractTextFromHTML(html string) (string, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return "", err + } + + text := doc.Find("body").Text() + text = strings.Join(strings.Fields(text), " ") + + return text, nil +} + +func convertHTMLToMarkdown(html string) (string, error) { + converter := md.NewConverter("", true, nil) + + markdown, err := converter.ConvertString(html) + if err != nil { + return "", err + } + + return markdown, nil +} diff --git a/internal/agent/tools/fetch.md b/internal/agent/tools/fetch.md new file mode 100644 index 0000000000000000000000000000000000000000..af96a0367d8cadceec8db34e791e3a5d9608d8e0 --- /dev/null +++ b/internal/agent/tools/fetch.md @@ -0,0 +1,45 @@ +Fetches raw content from URL and returns it in specified format without any AI processing. + + +Use this tool when you need: +- Raw, unprocessed content from a URL +- Direct access to API responses or JSON data +- HTML/text/markdown content without interpretation +- Simple, fast content retrieval without analysis +- To save tokens by avoiding AI processing + +DO NOT use this tool when you need to: +- Extract specific information from a webpage (use agentic_fetch instead) +- Answer questions about web content (use agentic_fetch instead) +- Analyze or summarize web pages (use agentic_fetch instead) + + + +- Provide URL to fetch content from +- Specify desired output format (text, markdown, or html) +- Optional timeout for request + + + +- Supports three output formats: text, markdown, html +- Auto-handles HTTP redirects +- Fast and lightweight - no AI processing +- Sets reasonable timeouts to prevent hanging +- Validates input parameters before requests + + + +- Max response size: 5MB +- Only supports HTTP and HTTPS protocols +- Cannot handle authentication or cookies +- Some websites may block automated requests +- Returns raw content only - no analysis or extraction + + + +- Use text format for plain text content or simple API responses +- Use markdown format for content that should be rendered with formatting +- Use html format when you need raw HTML structure +- Set appropriate timeouts for potentially slow websites +- If the user asks to analyze or extract from a page, use agentic_fetch instead + diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go index 8bdd7f8f8e12121b5a0dd6f59603bb4726fdce4c..35bf0e0de3e7c9307ac920f1d56b480fde8b863a 100644 --- a/internal/agent/tools/fetch_helpers.go +++ b/internal/agent/tools/fetch_helpers.go @@ -1,7 +1,9 @@ package tools import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -52,6 +54,13 @@ func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (s return "", fmt.Errorf("failed to convert HTML to markdown: %w", err) } content = markdown + } else if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "text/json") { + // Format JSON for better readability. + formatted, err := FormatJSON(content) + if err == nil { + content = formatted + } + // If formatting fails, keep original content. } return content, nil @@ -68,3 +77,20 @@ func ConvertHTMLToMarkdown(html string) (string, error) { return markdown, nil } + +// FormatJSON formats JSON content with proper indentation. +func FormatJSON(content string) (string, error) { + var data interface{} + if err := json.Unmarshal([]byte(content), &data); err != nil { + return "", err + } + + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetIndent("", " ") + if err := encoder.Encode(data); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/internal/agent/tools/fetch_types.go b/internal/agent/tools/fetch_types.go index 05e555cb9a9d4a2a03bf3857b73e084db6403e73..54754c286ae58a4de9caad1be253ddc2fd3d5377 100644 --- a/internal/agent/tools/fetch_types.go +++ b/internal/agent/tools/fetch_types.go @@ -1,7 +1,7 @@ package tools -// FetchToolName is the name of the fetch tool. -const FetchToolName = "fetch" +// AgenticFetchToolName is the name of the agentic fetch tool. +const AgenticFetchToolName = "agentic_fetch" // WebFetchToolName is the name of the web_fetch tool. const WebFetchToolName = "web_fetch" @@ -9,14 +9,14 @@ const WebFetchToolName = "web_fetch" // LargeContentThreshold is the size threshold for saving content to a file. const LargeContentThreshold = 50000 // 50KB -// FetchParams defines the parameters for the fetch tool. -type FetchParams struct { +// AgenticFetchParams defines the parameters for the agentic fetch tool. +type AgenticFetchParams struct { URL string `json:"url" description:"The URL to fetch content from"` Prompt string `json:"prompt" description:"The prompt to run on the fetched content"` } -// FetchPermissionsParams defines the permission parameters for the fetch tool. -type FetchPermissionsParams struct { +// AgenticFetchPermissionsParams defines the permission parameters for the agentic fetch tool. +type AgenticFetchPermissionsParams struct { URL string `json:"url"` Prompt string `json:"prompt"` } @@ -25,3 +25,17 @@ type FetchPermissionsParams struct { type WebFetchParams struct { URL string `json:"url" description:"The URL to fetch content from"` } + +// FetchParams defines the parameters for the simple fetch tool. +type FetchParams struct { + URL string `json:"url" description:"The URL to fetch content from"` + Format string `json:"format" description:"The format to return the content in (text, markdown, or html)"` + Timeout int `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"` +} + +// FetchPermissionsParams defines the permission parameters for the simple fetch tool. +type FetchPermissionsParams struct { + URL string `json:"url"` + Format string `json:"format"` + Timeout int `json:"timeout,omitempty"` +} diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index cde12c2a76f49e08e38ecb26ea1c9d707c83a597..39a2e9d1dbf9e7fcf63c445f9a393f5cc24b88f3 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -288,8 +288,13 @@ type LineScanner struct { } func NewLineScanner(r io.Reader) *LineScanner { + scanner := bufio.NewScanner(r) + // Increase buffer size to handle large lines (e.g., minified JSON, HTML) + // Default is 64KB, set to 1MB + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) return &LineScanner{ - scanner: bufio.NewScanner(r), + scanner: scanner, } } diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go index 27aabd298be2a615daea94dc15d4cba22233d592..42a5f9282bf7a04807be208fb9d69e3beebaaa99 100644 --- a/internal/agent/tools/web_fetch.go +++ b/internal/agent/tools/web_fetch.go @@ -3,7 +3,6 @@ package tools import ( "context" _ "embed" - "encoding/json" "fmt" "net/http" "os" @@ -33,10 +32,6 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool { WebFetchToolName, string(webFetchToolDescription), func(ctx context.Context, params WebFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { - return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil - } - if params.URL == "" { return fantasy.NewTextErrorResponse("url is required"), nil } diff --git a/internal/config/config.go b/internal/config/config.go index 99c70079223127c71c3768da40d4aec0b55d62c7..acca8b62c8000e69a64a5fe41b4f756f2af1d8c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -475,6 +475,7 @@ func allToolNames() []string { "lsp_diagnostics", "lsp_references", "fetch", + "agentic_fetch", "glob", "grep", "ls", diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 0b582489851ecdb494b2af55d2643e9d3602b165..7647a9080aced65ae5b1ca47be60a2d15a16c3d7 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -636,8 +636,8 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult for _, tc := range msg.ToolCalls() { options := m.buildToolCallOptions(tc, msg, toolResultMap) uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...)) - // If this tool call is the agent tool, fetch nested tool calls - if tc.Name == agent.AgentToolName || tc.Name == tools.FetchToolName { + // If this tool call is the agent tool or agentic fetch, fetch nested tool calls + if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID) nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID) nestedToolResultMap := m.buildToolResultMap(nestedMessages) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index c06110eb23aaac6e5488b6c953615850992586e2..f8873a726ab23800fa7102df1cd8fae7cf6294c9 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -168,7 +168,8 @@ func init() { registry.register(tools.EditToolName, func() renderer { return editRenderer{} }) registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} }) registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} }) - registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} }) + registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} }) + registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} }) registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} }) registry.register(tools.GlobToolName, func() renderer { return globRenderer{} }) registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} }) @@ -397,15 +398,54 @@ func (wr writeRenderer) Render(v *toolCallCmp) string { // Fetch renderer // ----------------------------------------------------------------------------- -// fetchRenderer handles URL fetching with format-specific content display -type fetchRenderer struct { +// simpleFetchRenderer handles URL fetching with format-specific content display +type simpleFetchRenderer struct { + baseRenderer +} + +// Render displays the fetched URL with format and timeout parameters +func (fr simpleFetchRenderer) Render(v *toolCallCmp) string { + var params tools.FetchParams + var args []string + if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { + args = newParamBuilder(). + addMain(params.URL). + addKeyValue("format", params.Format). + addKeyValue("timeout", formatTimeout(params.Timeout)). + build() + } + + return fr.renderWithParams(v, "Fetch", args, func() string { + file := fr.getFileExtension(params.Format) + return renderCodeContent(v, file, v.result.Content, 0) + }) +} + +// getFileExtension returns appropriate file extension for syntax highlighting +func (fr simpleFetchRenderer) getFileExtension(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} + +// ----------------------------------------------------------------------------- +// Agentic fetch renderer +// ----------------------------------------------------------------------------- + +// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls +type agenticFetchRenderer struct { baseRenderer } // Render displays the fetched URL with prompt parameter and nested tool calls -func (fr fetchRenderer) Render(v *toolCallCmp) string { +func (fr agenticFetchRenderer) Render(v *toolCallCmp) string { t := styles.CurrentTheme() - var params tools.FetchParams + var params tools.AgenticFetchParams var args []string if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil { args = newParamBuilder(). @@ -416,7 +456,7 @@ func (fr fetchRenderer) Render(v *toolCallCmp) string { prompt := params.Prompt prompt = strings.ReplaceAll(prompt, "\n", " ") - header := fr.makeHeader(v, "Fetch", v.textWidth(), args...) + header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...) if res, done := earlyState(header, v); v.cancelled && done { return res } @@ -484,7 +524,7 @@ type webFetchRenderer struct { // Render displays a compact view of web_fetch with just the URL in a link style func (wfr webFetchRenderer) Render(v *toolCallCmp) string { - var params tools.FetchParams + var params tools.WebFetchParams var args []string if err := wfr.unmarshalParams(v.call.Input, ¶ms); err == nil { args = newParamBuilder(). @@ -979,6 +1019,8 @@ func prettifyToolName(name string) string { return "Multi-Edit" case tools.FetchToolName: return "Fetch" + case tools.AgenticFetchToolName: + return "Agentic Fetch" case tools.WebFetchToolName: return "Fetching" case tools.GlobToolName: diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index aa50f604d2ecf73bd1d27f704dc86b9655577608..c43f7f4a4a3a2c10f2a496470e5b7942c9990205 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -286,6 +286,19 @@ func (m *toolCallCmp) formatParametersForCopy() string { } case tools.FetchToolName: var params tools.FetchParams + if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { + var parts []string + parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) + if params.Format != "" { + parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format)) + } + if params.Timeout > 0 { + parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout)) + } + return strings.Join(parts, "\n") + } + case tools.AgenticFetchToolName: + var params tools.AgenticFetchParams if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil { var parts []string parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL)) @@ -397,6 +410,8 @@ func (m *toolCallCmp) formatResultForCopy() string { return m.formatWriteResultForCopy() case tools.FetchToolName: return m.formatFetchResultForCopy() + case tools.AgenticFetchToolName: + return m.formatAgenticFetchResultForCopy() case tools.WebFetchToolName: return m.formatWebFetchResultForCopy() case agent.AgentToolName: @@ -608,6 +623,29 @@ func (m *toolCallCmp) formatFetchResultForCopy() string { return m.result.Content } + var result strings.Builder + if params.URL != "" { + result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) + } + if params.Format != "" { + result.WriteString(fmt.Sprintf("Format: %s\n", params.Format)) + } + if params.Timeout > 0 { + result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout)) + } + result.WriteString("\n") + + result.WriteString(m.result.Content) + + return result.String() +} + +func (m *toolCallCmp) formatAgenticFetchResultForCopy() string { + var params tools.AgenticFetchParams + if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil { + return m.result.Content + } + var result strings.Builder if params.URL != "" { result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 4ba4a1eda6df887acbee62fd18bfbeec0ffae134..a142b97d14ee765c44399974953572a34fbe1c19 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -373,6 +373,8 @@ func (p *permissionDialogCmp) renderHeader() string { ) case tools.FetchToolName: headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL")) + case tools.AgenticFetchToolName: + headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL")) case tools.ViewToolName: params := p.permission.Params.(tools.ViewPermissionsParams) fileKey := t.S().Muted.Render("File") @@ -427,6 +429,8 @@ func (p *permissionDialogCmp) getOrGenerateContent() string { content = p.generateMultiEditContent() case tools.FetchToolName: content = p.generateFetchContent() + case tools.AgenticFetchToolName: + content = p.generateAgenticFetchContent() case tools.ViewToolName: content = p.generateViewContent() case tools.LSToolName: @@ -570,6 +574,20 @@ func (p *permissionDialogCmp) generateFetchContent() string { return "" } +func (p *permissionDialogCmp) generateAgenticFetchContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base.Background(t.BgSubtle) + if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok { + content := fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt) + finalContent := baseStyle. + Padding(1, 2). + Width(p.contentViewPort.Width()). + Render(content) + return finalContent + } + return "" +} + func (p *permissionDialogCmp) generateViewContent() string { t := styles.CurrentTheme() baseStyle := t.S().Base.Background(t.BgSubtle) @@ -776,6 +794,9 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { case tools.FetchToolName: p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.3) + case tools.AgenticFetchToolName: + p.width = int(float64(p.wWidth) * 0.8) + p.height = int(float64(p.wHeight) * 0.4) case tools.ViewToolName: p.width = int(float64(p.wWidth) * 0.8) p.height = int(float64(p.wHeight) * 0.4)