//go:build windows || darwin package tools import ( "context" "fmt" "net/url" "regexp" "strings" "sync" "time" "github.com/ollama/ollama/app/ui/responses" ) type PageType string const ( PageTypeSearchResults PageType = "initial_results" PageTypeWebpage PageType = "webpage" ) // DefaultViewTokens is the number of tokens to show to the model used when calling displayPage const DefaultViewTokens = 1024 /* The Browser tool provides web browsing capability for gpt-oss. The model uses the tool by usually doing a search first and then choosing to either open a page, find a term in a page, or do another search. The tool optionally may open a URL directly - especially if one is passed in. Each action is saved into an append-only page stack `responses.BrowserStateData` to keep track of the history of the browsing session. Each `Execute()` for a tool returns the full current state of the browser. ui.go manages the browser state representation between the tool, ui, and db. A new Browser object is created per request - the state is reconstructed by ui.go. The initialization of the browser will receive a `responses.BrowserStateData` with the stitched history. */ // BrowserState manages the browsing session on a per-chat basis type BrowserState struct { mu sync.RWMutex Data *responses.BrowserStateData } type Browser struct { state *BrowserState } // State is only accessed in a single thread, as each chat has its own browser state func (b *Browser) State() *responses.BrowserStateData { b.state.mu.RLock() defer b.state.mu.RUnlock() return b.state.Data } func (b *Browser) savePage(page *responses.Page) { b.state.Data.URLToPage[page.URL] = page b.state.Data.PageStack = append(b.state.Data.PageStack, page.URL) } func (b *Browser) getPageFromStack(url string) (*responses.Page, error) { page, ok := b.state.Data.URLToPage[url] if !ok { return nil, fmt.Errorf("page not found for url %s", url) } return page, nil } func NewBrowser(state *responses.BrowserStateData) *Browser { if state == nil { state = &responses.BrowserStateData{ PageStack: []string{}, ViewTokens: DefaultViewTokens, URLToPage: make(map[string]*responses.Page), } } b := &BrowserState{ Data: state, } return &Browser{ state: b, } } type BrowserSearch struct { Browser webSearch *BrowserWebSearch } // NewBrowserSearch creates a new browser search instance func NewBrowserSearch(bb *Browser) *BrowserSearch { if bb == nil { bb = &Browser{ state: &BrowserState{ Data: &responses.BrowserStateData{ PageStack: []string{}, ViewTokens: DefaultViewTokens, URLToPage: make(map[string]*responses.Page), }, }, } } return &BrowserSearch{ Browser: *bb, webSearch: &BrowserWebSearch{}, } } func (b *BrowserSearch) Name() string { return "browser.search" } func (b *BrowserSearch) Description() string { return "Search the web for information" } func (b *BrowserSearch) Prompt() string { return "" } func (b *BrowserSearch) Schema() map[string]any { return map[string]any{} } func (b *BrowserSearch) Execute(ctx context.Context, args map[string]any) (any, string, error) { query, ok := args["query"].(string) if !ok { return nil, "", fmt.Errorf("query parameter is required") } topn, ok := args["topn"].(int) if !ok { topn = 5 } searchArgs := map[string]any{ "queries": []any{query}, "max_results": topn, } result, err := b.webSearch.Execute(ctx, searchArgs) if err != nil { return nil, "", fmt.Errorf("search error: %w", err) } searchResponse, ok := result.(*WebSearchResponse) if !ok { return nil, "", fmt.Errorf("invalid search results format") } // Build main search results page that contains all search results searchResultsPage := b.buildSearchResultsPageCollection(query, searchResponse) b.savePage(searchResultsPage) cursor := len(b.state.Data.PageStack) - 1 // cache result for each page for _, queryResults := range searchResponse.Results { for i, result := range queryResults { resultPage := b.buildSearchResultsPage(&result, i+1) // save to global only, do not add to visited stack b.state.Data.URLToPage[resultPage.URL] = resultPage } } page := searchResultsPage pageText, err := b.displayPage(page, cursor, 0, -1) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } func (b *Browser) buildSearchResultsPageCollection(query string, results *WebSearchResponse) *responses.Page { page := &responses.Page{ URL: "search_results_" + query, Title: query, Links: make(map[int]string), FetchedAt: time.Now(), } var textBuilder strings.Builder linkIdx := 0 // Add the header lines to match format textBuilder.WriteString("\n") // L0: empty textBuilder.WriteString("URL: \n") // L1: URL: (empty for search) textBuilder.WriteString("# Search Results\n") // L2: # Search Results textBuilder.WriteString("\n") // L3: empty for _, queryResults := range results.Results { for _, result := range queryResults { domain := result.URL if u, err := url.Parse(result.URL); err == nil && u.Host != "" { domain = u.Host domain = strings.TrimPrefix(domain, "www.") } linkFormat := fmt.Sprintf("* 【%d†%s†%s】", linkIdx, result.Title, domain) textBuilder.WriteString(linkFormat) numChars := min(len(result.Content.FullText), 400) snippet := strings.TrimSpace(result.Content.FullText[:numChars]) textBuilder.WriteString(snippet) textBuilder.WriteString("\n") page.Links[linkIdx] = result.URL linkIdx++ } } page.Text = textBuilder.String() page.Lines = wrapLines(page.Text, 80) return page } func (b *Browser) buildSearchResultsPage(result *WebSearchResult, linkIdx int) *responses.Page { page := &responses.Page{ URL: result.URL, Title: result.Title, Links: make(map[int]string), FetchedAt: time.Now(), } var textBuilder strings.Builder // Format the individual result page (only used when no full text is available) linkFormat := fmt.Sprintf("【%d†%s】", linkIdx, result.Title) textBuilder.WriteString(linkFormat) textBuilder.WriteString("\n") textBuilder.WriteString(fmt.Sprintf("URL: %s\n", result.URL)) numChars := min(len(result.Content.FullText), 300) textBuilder.WriteString(result.Content.FullText[:numChars]) textBuilder.WriteString("\n\n") // Only store link and snippet if we won't be processing full text later // (full text processing will handle all links consistently) if result.Content.FullText == "" { page.Links[linkIdx] = result.URL } // Use full text if available, otherwise use snippet if result.Content.FullText != "" { // Prepend the URL line to the full text page.Text = fmt.Sprintf("URL: %s\n%s", result.URL, result.Content.FullText) // Process markdown links in the full text processedText, processedLinks := processMarkdownLinks(page.Text) page.Text = processedText page.Links = processedLinks } else { page.Text = textBuilder.String() } page.Lines = wrapLines(page.Text, 80) return page } // getEndLoc calculates the end location for viewport based on token limits func (b *Browser) getEndLoc(loc, numLines, totalLines int, lines []string) int { if numLines <= 0 { // Auto-calculate based on viewTokens txt := b.joinLinesWithNumbers(lines[loc:]) // If text is very short, no need to truncate (at least 1 char per token) if len(txt) > b.state.Data.ViewTokens { // Simple heuristic: approximate token counting // Typical token is ~4 characters, but can be up to 128 chars maxCharsPerToken := 128 // upper bound for text to analyze upperBound := min((b.state.Data.ViewTokens+1)*maxCharsPerToken, len(txt)) textToAnalyze := txt[:upperBound] // Simple approximation: count tokens as ~4 chars each // This is less accurate than tiktoken but more performant approxTokens := len(textToAnalyze) / 4 if approxTokens > b.state.Data.ViewTokens { // Find the character position at viewTokens endIdx := min(b.state.Data.ViewTokens*4, len(txt)) // Count newlines up to that position to get line count numLines = strings.Count(txt[:endIdx], "\n") + 1 } else { numLines = totalLines } } else { numLines = totalLines } } return min(loc+numLines, totalLines) } // joinLinesWithNumbers creates a string with line numbers, matching Python's join_lines func (b *Browser) joinLinesWithNumbers(lines []string) string { var builder strings.Builder var hadZeroLine bool for i, line := range lines { if i == 0 { builder.WriteString("L0:\n") hadZeroLine = true } if hadZeroLine { builder.WriteString(fmt.Sprintf("L%d: %s\n", i+1, line)) } else { builder.WriteString(fmt.Sprintf("L%d: %s\n", i, line)) } } return builder.String() } // processMarkdownLinks finds all markdown links in the text and replaces them with the special format // Returns the processed text and a map of link IDs to URLs func processMarkdownLinks(text string) (string, map[int]string) { links := make(map[int]string) // Always start from 0 for consistent numbering across all pages linkID := 0 // First, handle multi-line markdown links by joining them // This regex finds markdown links that might be split across lines multiLinePattern := regexp.MustCompile(`\[([^\]]+)\]\s*\n\s*\(([^)]+)\)`) text = multiLinePattern.ReplaceAllStringFunc(text, func(match string) string { // Replace newlines with spaces in the match cleaned := strings.ReplaceAll(match, "\n", " ") // Remove extra spaces cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ") return cleaned }) // Now process all markdown links (including the cleaned multi-line ones) linkPattern := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) processedText := linkPattern.ReplaceAllStringFunc(text, func(match string) string { matches := linkPattern.FindStringSubmatch(match) if len(matches) != 3 { return match } linkText := strings.TrimSpace(matches[1]) linkURL := strings.TrimSpace(matches[2]) // Extract domain from URL domain := linkURL if u, err := url.Parse(linkURL); err == nil && u.Host != "" { domain = u.Host // Remove www. prefix if present domain = strings.TrimPrefix(domain, "www.") } // Create the formatted link formatted := fmt.Sprintf("【%d†%s†%s】", linkID, linkText, domain) // Store the link links[linkID] = linkURL linkID++ return formatted }) return processedText, links } func wrapLines(text string, width int) []string { if width <= 0 { width = 80 } lines := strings.Split(text, "\n") var wrapped []string for _, line := range lines { if line == "" { // Preserve empty lines wrapped = append(wrapped, "") } else if len(line) <= width { wrapped = append(wrapped, line) } else { // Word wrapping while preserving whitespace structure words := strings.Fields(line) if len(words) == 0 { // Line with only whitespace wrapped = append(wrapped, line) continue } currentLine := "" for _, word := range words { // Check if adding this word would exceed width testLine := currentLine if testLine != "" { testLine += " " } testLine += word if len(testLine) > width && currentLine != "" { // Current line would be too long, wrap it wrapped = append(wrapped, currentLine) currentLine = word } else { // Add word to current line if currentLine != "" { currentLine += " " } currentLine += word } } // Add any remaining content if currentLine != "" { wrapped = append(wrapped, currentLine) } } } return wrapped } // displayPage formats and returns the page display for the model func (b *Browser) displayPage(page *responses.Page, cursor, loc, numLines int) (string, error) { totalLines := len(page.Lines) if loc >= totalLines { return "", fmt.Errorf("invalid location: %d (max: %d)", loc, totalLines-1) } // get viewport end location endLoc := b.getEndLoc(loc, numLines, totalLines, page.Lines) var displayBuilder strings.Builder displayBuilder.WriteString(fmt.Sprintf("[%d] %s", cursor, page.Title)) if page.URL != "" { displayBuilder.WriteString(fmt.Sprintf("(%s)\n", page.URL)) } else { displayBuilder.WriteString("\n") } displayBuilder.WriteString(fmt.Sprintf("**viewing lines [%d - %d] of %d**\n\n", loc, endLoc-1, totalLines-1)) // Content with line numbers var hadZeroLine bool for i := loc; i < endLoc; i++ { if i == 0 { displayBuilder.WriteString("L0:\n") hadZeroLine = true } if hadZeroLine { displayBuilder.WriteString(fmt.Sprintf("L%d: %s\n", i+1, page.Lines[i])) } else { displayBuilder.WriteString(fmt.Sprintf("L%d: %s\n", i, page.Lines[i])) } } return displayBuilder.String(), nil } type BrowserOpen struct { Browser crawlPage *BrowserCrawler } func NewBrowserOpen(bb *Browser) *BrowserOpen { if bb == nil { bb = &Browser{ state: &BrowserState{ Data: &responses.BrowserStateData{ PageStack: []string{}, ViewTokens: DefaultViewTokens, URLToPage: make(map[string]*responses.Page), }, }, } } return &BrowserOpen{ Browser: *bb, crawlPage: &BrowserCrawler{}, } } func (b *BrowserOpen) Name() string { return "browser.open" } func (b *BrowserOpen) Description() string { return "Open a link in the browser" } func (b *BrowserOpen) Prompt() string { return "" } func (b *BrowserOpen) Schema() map[string]any { return map[string]any{} } func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, string, error) { // Get cursor parameter first cursor := -1 if c, ok := args["cursor"].(float64); ok { cursor = int(c) } else if c, ok := args["cursor"].(int); ok { cursor = c } // Get loc parameter loc := 0 if l, ok := args["loc"].(float64); ok { loc = int(l) } else if l, ok := args["loc"].(int); ok { loc = l } // Get num_lines parameter numLines := -1 if n, ok := args["num_lines"].(float64); ok { numLines = int(n) } else if n, ok := args["num_lines"].(int); ok { numLines = n } // get page from cursor var page *responses.Page if cursor >= 0 { if cursor >= len(b.state.Data.PageStack) { return nil, "", fmt.Errorf("cursor %d is out of range (pageStack length: %d)", cursor, len(b.state.Data.PageStack)) } var err error page, err = b.getPageFromStack(b.state.Data.PageStack[cursor]) if err != nil { return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err) } } else { // get last page if len(b.state.Data.PageStack) != 0 { pageURL := b.state.Data.PageStack[len(b.state.Data.PageStack)-1] var err error page, err = b.getPageFromStack(pageURL) if err != nil { return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err) } } } // Try to get id as string (URL) first if url, ok := args["id"].(string); ok { // Check if we already have this page cached if existingPage, ok := b.state.Data.URLToPage[url]; ok { // Use cached page b.savePage(existingPage) // Always update cursor to point to the newly added page cursor = len(b.state.Data.PageStack) - 1 pageText, err := b.displayPage(existingPage, cursor, loc, numLines) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } // Page not in cache, need to crawl it if b.crawlPage == nil { b.crawlPage = &BrowserCrawler{} } crawlResponse, err := b.crawlPage.Execute(ctx, map[string]any{ "urls": []any{url}, "latest": false, }) if err != nil { return nil, "", fmt.Errorf("failed to crawl URL %s: %w", url, err) } newPage, err := b.buildPageFromCrawlResult(url, crawlResponse) if err != nil { return nil, "", fmt.Errorf("failed to build page from crawl result: %w", err) } // Need to fall through if first search is directly an open command - no existing page b.savePage(newPage) // Always update cursor to point to the newly added page cursor = len(b.state.Data.PageStack) - 1 pageText, err := b.displayPage(newPage, cursor, loc, numLines) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } // Try to get id as integer (link ID from current page) if id, ok := args["id"].(float64); ok { if page == nil { return nil, "", fmt.Errorf("no current page to resolve link from") } idInt := int(id) pageURL, ok := page.Links[idInt] if !ok { return nil, "", fmt.Errorf("invalid link id %d", idInt) } // Check if we have the linked page cached newPage, ok := b.state.Data.URLToPage[pageURL] if !ok { if b.crawlPage == nil { b.crawlPage = &BrowserCrawler{} } crawlResponse, err := b.crawlPage.Execute(ctx, map[string]any{ "urls": []any{pageURL}, "latest": false, }) if err != nil { return nil, "", fmt.Errorf("failed to crawl URL %s: %w", pageURL, err) } // Create new page from crawl result newPage, err = b.buildPageFromCrawlResult(pageURL, crawlResponse) if err != nil { return nil, "", fmt.Errorf("failed to build page from crawl result: %w", err) } } // Add to history stack regardless of cache status b.savePage(newPage) // Always update cursor to point to the newly added page cursor = len(b.state.Data.PageStack) - 1 pageText, err := b.displayPage(newPage, cursor, loc, numLines) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } // If no id provided, just display current page if page == nil { return nil, "", fmt.Errorf("no current page to display") } // Only add to PageStack without updating URLToPage b.state.Data.PageStack = append(b.state.Data.PageStack, page.URL) cursor = len(b.state.Data.PageStack) - 1 pageText, err := b.displayPage(page, cursor, loc, numLines) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } // buildPageFromCrawlResult creates a Page from crawl API results func (b *Browser) buildPageFromCrawlResult(requestedURL string, crawlResponse *CrawlResponse) (*responses.Page, error) { // Initialize page with defaults page := &responses.Page{ URL: requestedURL, Title: requestedURL, Text: "", Links: make(map[int]string), FetchedAt: time.Now(), } // Process crawl results - the API returns results grouped by URL for url, urlResults := range crawlResponse.Results { if len(urlResults) > 0 { // Get the first result for this URL result := urlResults[0] // Extract content if result.Content.FullText != "" { page.Text = result.Content.FullText } // Extract title if available if result.Title != "" { page.Title = result.Title } // Update URL to the actual URL from results page.URL = url // Extract links if available from extras for i, link := range result.Extras.Links { if link.Href != "" { page.Links[i] = link.Href } else if link.URL != "" { page.Links[i] = link.URL } } // Only process the first URL's results break } } // If no text was extracted, set a default message if page.Text == "" { page.Text = "No content could be extracted from this page." } else { // Prepend the URL line to match Python implementation page.Text = fmt.Sprintf("URL: %s\n%s", page.URL, page.Text) } // Process markdown links in the text processedText, processedLinks := processMarkdownLinks(page.Text) page.Text = processedText page.Links = processedLinks // Wrap lines for display page.Lines = wrapLines(page.Text, 80) return page, nil } type BrowserFind struct { Browser } func NewBrowserFind(bb *Browser) *BrowserFind { return &BrowserFind{ Browser: *bb, } } func (b *BrowserFind) Name() string { return "browser.find" } func (b *BrowserFind) Description() string { return "Find a term in the browser" } func (b *BrowserFind) Prompt() string { return "" } func (b *BrowserFind) Schema() map[string]any { return map[string]any{} } func (b *BrowserFind) Execute(ctx context.Context, args map[string]any) (any, string, error) { pattern, ok := args["pattern"].(string) if !ok { return nil, "", fmt.Errorf("pattern parameter is required") } // Get cursor parameter if provided, default to current page cursor := -1 if c, ok := args["cursor"].(float64); ok { cursor = int(c) } // Get the page to search in var page *responses.Page if cursor == -1 { // Use current page if len(b.state.Data.PageStack) == 0 { return nil, "", fmt.Errorf("no pages to search in") } var err error page, err = b.getPageFromStack(b.state.Data.PageStack[len(b.state.Data.PageStack)-1]) if err != nil { return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err) } } else { // Use specific cursor if cursor < 0 || cursor >= len(b.state.Data.PageStack) { return nil, "", fmt.Errorf("cursor %d is out of range [0-%d]", cursor, len(b.state.Data.PageStack)-1) } var err error page, err = b.getPageFromStack(b.state.Data.PageStack[cursor]) if err != nil { return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err) } } if page == nil { return nil, "", fmt.Errorf("page not found") } // Create find results page findPage := b.buildFindResultsPage(pattern, page) // Add the find results page to state b.savePage(findPage) newCursor := len(b.state.Data.PageStack) - 1 pageText, err := b.displayPage(findPage, newCursor, 0, -1) if err != nil { return nil, "", fmt.Errorf("failed to display page: %w", err) } return b.state.Data, pageText, nil } func (b *Browser) buildFindResultsPage(pattern string, page *responses.Page) *responses.Page { findPage := &responses.Page{ Title: fmt.Sprintf("Find results for text: `%s` in `%s`", pattern, page.Title), Links: make(map[int]string), FetchedAt: time.Now(), } findPage.URL = fmt.Sprintf("find_results_%s", pattern) var textBuilder strings.Builder matchIdx := 0 maxResults := 50 numShowLines := 4 patternLower := strings.ToLower(pattern) // Search through the page lines following the reference algorithm var resultChunks []string lineIdx := 0 for lineIdx < len(page.Lines) { line := page.Lines[lineIdx] lineLower := strings.ToLower(line) if !strings.Contains(lineLower, patternLower) { lineIdx++ continue } // Build snippet context endLine := min(lineIdx+numShowLines, len(page.Lines)) var snippetBuilder strings.Builder for j := lineIdx; j < endLine; j++ { snippetBuilder.WriteString(page.Lines[j]) if j < endLine-1 { snippetBuilder.WriteString("\n") } } snippet := snippetBuilder.String() // Format the match linkFormat := fmt.Sprintf("【%d†match at L%d】", matchIdx, lineIdx) resultChunk := fmt.Sprintf("%s\n%s", linkFormat, snippet) resultChunks = append(resultChunks, resultChunk) if len(resultChunks) >= maxResults { break } matchIdx++ lineIdx += numShowLines } // Build final display text if len(resultChunks) > 0 { textBuilder.WriteString(strings.Join(resultChunks, "\n\n")) } if matchIdx == 0 { findPage.Text = fmt.Sprintf("No `find` results for pattern: `%s`", pattern) } else { findPage.Text = textBuilder.String() } findPage.Lines = wrapLines(findPage.Text, 80) return findPage }