diff --git a/cmd/cmd.go b/cmd/cmd.go index 8a0811638..09940bda5 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -537,6 +537,7 @@ func RunHandler(cmd *cobra.Command, args []string) error { // Check for experimental flag isExperimental, _ := cmd.Flags().GetBool("experimental") yoloMode, _ := cmd.Flags().GetBool("experimental-yolo") + enableWebsearch, _ := cmd.Flags().GetBool("experimental-websearch") if interactive { if err := loadOrUnloadModel(cmd, &opts); err != nil { @@ -566,7 +567,7 @@ func RunHandler(cmd *cobra.Command, args []string) error { // Use experimental agent loop with tools if isExperimental { - return xcmd.GenerateInteractive(cmd, opts.Model, opts.WordWrap, opts.Options, opts.Think, opts.HideThinking, opts.KeepAlive, yoloMode) + return xcmd.GenerateInteractive(cmd, opts.Model, opts.WordWrap, opts.Options, opts.Think, opts.HideThinking, opts.KeepAlive, yoloMode, enableWebsearch) } return generateInteractive(cmd, opts) @@ -1786,6 +1787,7 @@ func NewCLI() *cobra.Command { runCmd.Flags().Int("dimensions", 0, "Truncate output embeddings to specified dimension (embedding models only)") runCmd.Flags().Bool("experimental", false, "Enable experimental agent loop with tools") runCmd.Flags().Bool("experimental-yolo", false, "Skip all tool approval prompts (use with caution)") + runCmd.Flags().Bool("experimental-websearch", false, "Enable web search tool in experimental mode") // Image generation flags (width, height, steps, seed, etc.) imagegen.RegisterFlags(runCmd) diff --git a/x/agent/approval.go b/x/agent/approval.go index 1e1f3f0d2..fa1084260 100644 --- a/x/agent/approval.go +++ b/x/agent/approval.go @@ -41,6 +41,7 @@ var optionLabels = []string{ var toolDisplayNames = map[string]string{ "bash": "Bash", "web_search": "Web Search", + "web_fetch": "Web Fetch", } // ToolDisplayName returns the human-readable display name for a tool. @@ -565,6 +566,16 @@ func formatToolDisplay(toolName string, args map[string]any) string { } } + // For web fetch, show URL and internet notice + if toolName == "web_fetch" { + if url, ok := args["url"].(string); ok { + sb.WriteString(fmt.Sprintf("Tool: %s\n", displayName)) + sb.WriteString(fmt.Sprintf("URL: %s\n", url)) + sb.WriteString("Uses internet via ollama.com") + return sb.String() + } + } + // Generic display sb.WriteString(fmt.Sprintf("Tool: %s", displayName)) if len(args) > 0 { @@ -1017,6 +1028,16 @@ func FormatApprovalResult(toolName string, args map[string]any, result ApprovalR } } + if toolName == "web_fetch" { + if url, ok := args["url"].(string); ok { + // Truncate long URLs + if len(url) > 50 { + url = url[:47] + "..." + } + return fmt.Sprintf("\033[1m%s:\033[0m %s: %s", label, displayName, url) + } + } + return fmt.Sprintf("\033[1m%s:\033[0m %s", label, displayName) } diff --git a/x/cmd/run.go b/x/cmd/run.go index db5522f5e..f32254ebe 100644 --- a/x/cmd/run.go +++ b/x/cmd/run.go @@ -650,7 +650,8 @@ func checkModelCapabilities(ctx context.Context, modelName string) (supportsTool // GenerateInteractive runs an interactive agent session. // This is called from cmd.go when --experimental flag is set. // If yoloMode is true, all tool approvals are skipped. -func GenerateInteractive(cmd *cobra.Command, modelName string, wordWrap bool, options map[string]any, think *api.ThinkValue, hideThinking bool, keepAlive *api.Duration, yoloMode bool) error { +// If enableWebsearch is true, the web search tool is registered. +func GenerateInteractive(cmd *cobra.Command, modelName string, wordWrap bool, options map[string]any, think *api.ThinkValue, hideThinking bool, keepAlive *api.Duration, yoloMode bool, enableWebsearch bool) error { scanner, err := readline.New(readline.Prompt{ Prompt: ">>> ", AltPrompt: "... ", @@ -676,6 +677,12 @@ func GenerateInteractive(cmd *cobra.Command, modelName string, wordWrap bool, op if supportsTools { toolRegistry = tools.DefaultRegistry() + // Register web search and web fetch tools if enabled via flag + if enableWebsearch { + toolRegistry.RegisterWebSearch() + toolRegistry.RegisterWebFetch() + } + if toolRegistry.Has("bash") { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "This experimental version of Ollama has the \033[1mbash\033[0m tool enabled.") @@ -683,6 +690,11 @@ func GenerateInteractive(cmd *cobra.Command, modelName string, wordWrap bool, op fmt.Fprintln(os.Stderr) } + if toolRegistry.Has("web_search") || toolRegistry.Has("web_fetch") { + fmt.Fprintln(os.Stderr, "The \033[1mWeb Search\033[0m and \033[1mWeb Fetch\033[0m tools are enabled. Models can search and fetch web content via ollama.com.") + fmt.Fprintln(os.Stderr) + } + if yoloMode { fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m yolo mode - all tool approvals will be skipped\n") } diff --git a/x/tools/registry.go b/x/tools/registry.go index 881a61bb5..8a43db0dc 100644 --- a/x/tools/registry.go +++ b/x/tools/registry.go @@ -54,6 +54,16 @@ func (r *Registry) RegisterBash() { r.Register(&BashTool{}) } +// RegisterWebSearch adds the web search tool to the registry. +func (r *Registry) RegisterWebSearch() { + r.Register(&WebSearchTool{}) +} + +// RegisterWebFetch adds the web fetch tool to the registry. +func (r *Registry) RegisterWebFetch() { + r.Register(&WebFetchTool{}) +} + // Get retrieves a tool by name. func (r *Registry) Get(name string) (Tool, bool) { tool, ok := r.tools[name] diff --git a/x/tools/webfetch.go b/x/tools/webfetch.go new file mode 100644 index 000000000..82a006233 --- /dev/null +++ b/x/tools/webfetch.go @@ -0,0 +1,162 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/auth" +) + +const ( + webFetchAPI = "https://ollama.com/api/web_fetch" + webFetchTimeout = 30 * time.Second +) + +// ErrWebFetchAuthRequired is returned when web fetch requires authentication +var ErrWebFetchAuthRequired = errors.New("web fetch requires authentication") + +// WebFetchTool implements web page fetching using Ollama's hosted API. +type WebFetchTool struct{} + +// Name returns the tool name. +func (w *WebFetchTool) Name() string { + return "web_fetch" +} + +// Description returns a description of the tool. +func (w *WebFetchTool) Description() string { + return "Fetch and extract text content from a web page. Use this to read the full content of a URL found in search results or provided by the user." +} + +// Schema returns the tool's parameter schema. +func (w *WebFetchTool) Schema() api.ToolFunction { + props := api.NewToolPropertiesMap() + props.Set("url", api.ToolProperty{ + Type: api.PropertyType{"string"}, + Description: "The URL to fetch and extract content from", + }) + return api.ToolFunction{ + Name: w.Name(), + Description: w.Description(), + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: props, + Required: []string{"url"}, + }, + } +} + +// webFetchRequest is the request body for the web fetch API. +type webFetchRequest struct { + URL string `json:"url"` +} + +// webFetchResponse is the response from the web fetch API. +type webFetchResponse struct { + Title string `json:"title"` + Content string `json:"content"` + Links []string `json:"links,omitempty"` +} + +// Execute fetches content from a web page. +// Uses Ollama key signing for authentication - this makes requests via ollama.com API. +func (w *WebFetchTool) Execute(args map[string]any) (string, error) { + urlStr, ok := args["url"].(string) + if !ok || urlStr == "" { + return "", fmt.Errorf("url parameter is required") + } + + // Validate URL + if _, err := url.Parse(urlStr); err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + + // Prepare request + reqBody := webFetchRequest{ + URL: urlStr, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshaling request: %w", err) + } + + // Parse URL and add timestamp for signing + fetchURL, err := url.Parse(webFetchAPI) + if err != nil { + return "", fmt.Errorf("parsing fetch URL: %w", err) + } + + q := fetchURL.Query() + q.Add("ts", strconv.FormatInt(time.Now().Unix(), 10)) + fetchURL.RawQuery = q.Encode() + + // Sign the request using Ollama key (~/.ollama/id_ed25519) + ctx := context.Background() + data := fmt.Appendf(nil, "%s,%s", http.MethodPost, fetchURL.RequestURI()) + signature, err := auth.Sign(ctx, data) + if err != nil { + return "", fmt.Errorf("signing request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fetchURL.String(), bytes.NewBuffer(jsonBody)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if signature != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature)) + } + + // Send request + client := &http.Client{Timeout: webFetchTimeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + return "", ErrWebFetchAuthRequired + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("web fetch API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var fetchResp webFetchResponse + if err := json.Unmarshal(body, &fetchResp); err != nil { + return "", fmt.Errorf("parsing response: %w", err) + } + + // Format result + var sb strings.Builder + if fetchResp.Title != "" { + sb.WriteString(fmt.Sprintf("Title: %s\n\n", fetchResp.Title)) + } + + if fetchResp.Content != "" { + sb.WriteString("Content:\n") + sb.WriteString(fetchResp.Content) + } else { + sb.WriteString("No content could be extracted from the page.") + } + + return sb.String(), nil +}