v0.0.3: add built-in web tools
Add no-key web_search and web_fetch tools. Fetch pages with net/http and parse HTML content. Document built-in web capabilities.
This commit is contained in:
@@ -11,6 +11,7 @@ model/provider switching, and local tools.
|
||||
- Multiple providers from `~/.agentu/config.yaml`.
|
||||
- Session-only provider, model, and thinking-level switching.
|
||||
- Read-only project tools enabled by default.
|
||||
- Built-in web search and URL fetching.
|
||||
- `--yolo` mode for file writes and shell execution.
|
||||
|
||||
## Quick Start
|
||||
@@ -58,6 +59,10 @@ agent:
|
||||
tool_timeout: 30s
|
||||
```
|
||||
|
||||
Web tools are enabled by default with a built-in no-key backend. agentu exposes
|
||||
`web_search` for current or external web information and `web_fetch` for reading
|
||||
a known URL.
|
||||
|
||||
Provider names are the keys under `providers`. At startup, agentu selects the
|
||||
first provider by name. Use `/model provider <name>` to switch providers for the
|
||||
current session.
|
||||
@@ -112,6 +117,11 @@ Read-only tools are available by default:
|
||||
- `file_list`
|
||||
- `file_search`
|
||||
|
||||
Web tools are also available in read-only mode:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
|
||||
`--yolo` additionally enables:
|
||||
|
||||
- `file_write`
|
||||
|
||||
@@ -60,6 +60,8 @@ func run() error {
|
||||
} else {
|
||||
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir)
|
||||
}
|
||||
registry.Register(tools.NewBuiltinSearchTool(http.DefaultClient))
|
||||
registry.Register(tools.NewBuiltinFetchTool(http.DefaultClient))
|
||||
systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
|
||||
|
||||
assistant := agent.New(agent.Options{
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
golang.org/x/net v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -39,7 +40,6 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
|
||||
+11
-2
@@ -82,6 +82,9 @@ func Builtins(workingDir string) *Registry {
|
||||
}
|
||||
|
||||
func AgentInstructions(workingDir string, yolo bool) string {
|
||||
searchInstructions := webSearchInstructions()
|
||||
readOnlyTools := "file_list, file_read, file_search, web_search, web_fetch"
|
||||
yoloTools := "file_list, file_read, file_search, file_write, shell_run, web_search, web_fetch"
|
||||
if yolo {
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
@@ -92,8 +95,9 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands.
|
||||
- Do not use file tools as a substitute for a command execution request.
|
||||
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search, file_write, shell_run.`, workingDir)
|
||||
- Available tools: %s.`, workingDir, searchInstructions, yoloTools)
|
||||
}
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
@@ -102,8 +106,13 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- Do not explore the project preemptively. Do not call file_list just to discover context unless the user asks about project files or the answer truly depends on local files.
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: file_list, file_read, file_search.`, workingDir)
|
||||
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
|
||||
}
|
||||
|
||||
func webSearchInstructions() string {
|
||||
return "When the user's request depends on current or external web information, use web_search. This includes news, prices, laws, schedules, software versions, product recommendations, or requests for the latest information. Use web_fetch to read a source URL when search snippets are not enough, when the user provides a URL, or when the user names a specific website to inspect. Base answers on returned sources and include source URLs when useful."
|
||||
}
|
||||
|
||||
func JSONSchema(schema string) json.RawMessage {
|
||||
|
||||
@@ -84,6 +84,27 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCanExposeWebSearch(t *testing.T) {
|
||||
registry := ReadOnlyBuiltins(t.TempDir())
|
||||
registry.Register(NewBuiltinSearchTool(nil))
|
||||
registry.Register(NewBuiltinFetchTool(nil))
|
||||
|
||||
var names []string
|
||||
for _, def := range registry.Definitions() {
|
||||
names = append(names, def.Function.Name)
|
||||
}
|
||||
for _, want := range []string{"web_search", "web_fetch"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"web_search", "web_fetch"} {
|
||||
if _, ok := registry.Get(want); !ok {
|
||||
t.Fatalf("%s missing", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
registry := Builtins(t.TempDir())
|
||||
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
|
||||
@@ -108,3 +129,12 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
||||
withSearch := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, web_search, web_fetch"} {
|
||||
if !strings.Contains(withSearch, want) {
|
||||
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"agentu/internal/llm"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSearchBaseURL = "https://html.duckduckgo.com/html/"
|
||||
defaultUserAgent = "agentu/0.0.3"
|
||||
maxSearchResults = 20
|
||||
maxWebReadBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
type BuiltinSearchTool struct {
|
||||
baseURL string
|
||||
maxResults int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewBuiltinSearchTool(client *http.Client) *BuiltinSearchTool {
|
||||
return NewBuiltinSearchToolWithBaseURL(defaultSearchBaseURL, 5, client)
|
||||
}
|
||||
|
||||
func NewBuiltinSearchToolWithBaseURL(baseURL string, maxResults int, client *http.Client) *BuiltinSearchTool {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = defaultSearchBaseURL
|
||||
}
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &BuiltinSearchTool{
|
||||
baseURL: baseURL,
|
||||
maxResults: maxResults,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinSearchTool) Definition() llm.Tool {
|
||||
return webSearchDefinition()
|
||||
}
|
||||
|
||||
func webSearchDefinition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "web_search",
|
||||
Description: "Search the public web for current or external information. Use for recent facts, news, prices, schedules, versions, policies, recommendations, or other questions that need online sources. Results include source URLs.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query."},
|
||||
"max_results": {"type": "integer", "description": "Optional result count from 1 to 20. Defaults to 5.", "minimum": 1, "maximum": 20},
|
||||
"topic": {"type": "string", "description": "Optional topic hint. Accepted for compatibility but the built-in backend may ignore it.", "enum": ["general", "news", "finance"]}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args, err := parseWebSearchArgs(raw, t.maxResults)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(t.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse search endpoint: %w", err)
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("q", args.Query)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create search request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
return "", fmt.Errorf("web search failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
results, err := parseSearchResults(resp.Body, args.MaxResults)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatResult(formatWebSearchResults(args.Query, "", results)), nil
|
||||
}
|
||||
|
||||
type BuiltinFetchTool struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type webSearchArgs struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
type webSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type webFetchArgs struct {
|
||||
URL string `json:"url"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type webFetchResponse struct {
|
||||
Results []webFetchResult `json:"results"`
|
||||
FailedResults []webFetchFailed `json:"failed_results"`
|
||||
}
|
||||
|
||||
type webFetchResult struct {
|
||||
URL string `json:"url"`
|
||||
RawContent string `json:"raw_content"`
|
||||
}
|
||||
|
||||
type webFetchFailed struct {
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func NewBuiltinFetchTool(client *http.Client) *BuiltinFetchTool {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &BuiltinFetchTool{client: client}
|
||||
}
|
||||
|
||||
func (t *BuiltinFetchTool) Definition() llm.Tool {
|
||||
return webFetchDefinition()
|
||||
}
|
||||
|
||||
func webFetchDefinition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "web_fetch",
|
||||
Description: "Fetch and extract readable Markdown content from a public web URL. Use after web_search when snippets are not enough, or when the user gives a URL or website to inspect. Results include the source URL.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Public http or https URL to fetch. URLs without a scheme are treated as https URLs."},
|
||||
"query": {"type": "string", "description": "Optional extraction focus. Accepted for compatibility but the built-in backend may ignore it."}
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuiltinFetchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args, err := parseWebFetchArgs(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, args.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create fetch request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,text/plain,application/xhtml+xml")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
return "", fmt.Errorf("web fetch failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxWebReadBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
var content string
|
||||
if strings.Contains(contentType, "html") || contentType == "" {
|
||||
content, err = htmlToMarkdown(string(data), args.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if strings.Contains(contentType, "text/plain") || strings.Contains(contentType, "markdown") {
|
||||
content = strings.TrimSpace(string(data))
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
|
||||
result := webFetchResponse{
|
||||
Results: []webFetchResult{{
|
||||
URL: args.URL,
|
||||
RawContent: content,
|
||||
}},
|
||||
}
|
||||
return FormatResult(formatWebFetchResults(args.URL, result)), nil
|
||||
}
|
||||
|
||||
func parseWebSearchArgs(raw json.RawMessage, defaultMaxResults int) (webSearchArgs, error) {
|
||||
var args webSearchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return args, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
args.Query = strings.TrimSpace(args.Query)
|
||||
if args.Query == "" {
|
||||
return args, errors.New("query is required")
|
||||
}
|
||||
if args.MaxResults == 0 {
|
||||
args.MaxResults = defaultMaxResults
|
||||
}
|
||||
if args.MaxResults < 1 || args.MaxResults > maxSearchResults {
|
||||
return args, fmt.Errorf("max_results must be between 1 and %d", maxSearchResults)
|
||||
}
|
||||
args.Topic = strings.ToLower(strings.TrimSpace(args.Topic))
|
||||
if args.Topic == "" {
|
||||
args.Topic = "general"
|
||||
}
|
||||
switch args.Topic {
|
||||
case "general", "news", "finance":
|
||||
default:
|
||||
return args, fmt.Errorf("topic must be one of: general, news, finance")
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func parseWebFetchArgs(raw json.RawMessage) (webFetchArgs, error) {
|
||||
var args webFetchArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return args, fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
args.URL = strings.TrimSpace(args.URL)
|
||||
if args.URL == "" {
|
||||
return args, errors.New("url is required")
|
||||
}
|
||||
if !strings.Contains(args.URL, "://") {
|
||||
args.URL = "https://" + args.URL
|
||||
}
|
||||
parsed, err := url.Parse(args.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return args, errors.New("url must be a valid http or https URL")
|
||||
}
|
||||
args.Query = strings.TrimSpace(args.Query)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func formatWebSearchResults(query string, answer string, results []webSearchResult) string {
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No web search results for %q.", query)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Web search results for %q:\n", query)
|
||||
if strings.TrimSpace(answer) != "" {
|
||||
fmt.Fprintf(&b, "\nSummary: %s\n", strings.TrimSpace(answer))
|
||||
}
|
||||
for i, result := range results {
|
||||
title := strings.TrimSpace(result.Title)
|
||||
if title == "" {
|
||||
title = "Untitled"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%d. %s\n", i+1, title)
|
||||
fmt.Fprintf(&b, " Source: %s\n", strings.TrimSpace(result.URL))
|
||||
if content := strings.TrimSpace(result.Content); content != "" {
|
||||
fmt.Fprintf(&b, " Snippet: %s\n", content)
|
||||
}
|
||||
if result.Score > 0 {
|
||||
fmt.Fprintf(&b, " Score: %.3f\n", result.Score)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func formatWebFetchResults(inputURL string, response webFetchResponse) string {
|
||||
var b strings.Builder
|
||||
if len(response.Results) == 0 {
|
||||
fmt.Fprintf(&b, "No readable web content extracted from %s.", inputURL)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Fetched web content:\n")
|
||||
for i, result := range response.Results {
|
||||
sourceURL := strings.TrimSpace(result.URL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = inputURL
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%d. Source: %s\n", i+1, sourceURL)
|
||||
if content := strings.TrimSpace(result.RawContent); content != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, failed := range response.FailedResults {
|
||||
sourceURL := strings.TrimSpace(failed.URL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = inputURL
|
||||
}
|
||||
if strings.TrimSpace(failed.Error) != "" {
|
||||
fmt.Fprintf(&b, "\nFetch failed for %s: %s", sourceURL, strings.TrimSpace(failed.Error))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func parseSearchResults(r io.Reader, maxResults int) ([]webSearchResult, error) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse search results: %w", err)
|
||||
}
|
||||
|
||||
results := make([]webSearchResult, 0, maxResults)
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if len(results) >= maxResults || n == nil {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "div" && hasClass(n, "result") {
|
||||
if result, ok := parseResultNode(n); ok {
|
||||
results = append(results, result)
|
||||
}
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseResultNode(n *html.Node) (webSearchResult, bool) {
|
||||
var result webSearchResult
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "a" {
|
||||
if hasClass(node, "result__a") && result.URL == "" {
|
||||
result.Title = cleanWebText(textContent(node))
|
||||
result.URL = decodeSearchURL(attr(node, "href"))
|
||||
}
|
||||
if hasClass(node, "result__snippet") && result.Content == "" {
|
||||
result.Content = cleanWebText(textContent(node))
|
||||
}
|
||||
}
|
||||
if node.Type == html.ElementNode && (node.Data == "div" || node.Data == "span") && hasClass(node, "result__snippet") && result.Content == "" {
|
||||
result.Content = cleanWebText(textContent(node))
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return result, result.Title != "" && result.URL != ""
|
||||
}
|
||||
|
||||
func decodeSearchURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
if target := parsed.Query().Get("uddg"); target != "" {
|
||||
if decoded, err := url.QueryUnescape(target); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func htmlToMarkdown(input string, sourceURL string) (string, error) {
|
||||
doc, err := html.Parse(strings.NewReader(input))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse html: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
var title string
|
||||
var walk func(*html.Node, int)
|
||||
walk = func(n *html.Node, listDepth int) {
|
||||
if n == nil || skipHTMLNode(n) {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "title" && title == "" {
|
||||
title = cleanWebText(textContent(n))
|
||||
return
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
writeInlineText(&b, n.Data)
|
||||
return
|
||||
}
|
||||
if n.Type != html.ElementNode {
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, listDepth)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch n.Data {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
writeBlockBreak(&b)
|
||||
level := int(n.Data[1] - '0')
|
||||
b.WriteString(strings.Repeat("#", level))
|
||||
b.WriteString(" ")
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "p", "div", "section", "article", "header", "footer", "main", "tr":
|
||||
writeBlockBreak(&b)
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "br":
|
||||
b.WriteString("\n")
|
||||
case "li":
|
||||
writeBlockBreak(&b)
|
||||
b.WriteString(strings.Repeat(" ", listDepth))
|
||||
b.WriteString("- ")
|
||||
writeChildren(&b, n, listDepth+1, walk)
|
||||
writeBlockBreak(&b)
|
||||
case "a":
|
||||
label := cleanWebText(textContent(n))
|
||||
href := strings.TrimSpace(attr(n, "href"))
|
||||
if href != "" && label != "" {
|
||||
href = resolveURL(sourceURL, href)
|
||||
b.WriteString(label)
|
||||
b.WriteString(" (")
|
||||
b.WriteString(href)
|
||||
b.WriteString(")")
|
||||
return
|
||||
}
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
default:
|
||||
writeChildren(&b, n, listDepth, walk)
|
||||
}
|
||||
}
|
||||
walk(doc, 0)
|
||||
|
||||
content := normalizeMarkdownText(b.String())
|
||||
if title != "" && !strings.Contains(content, title) {
|
||||
content = "# " + title + "\n\n" + content
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", errors.New("no readable content found")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func writeChildren(b *strings.Builder, n *html.Node, listDepth int, walk func(*html.Node, int)) {
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, listDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInlineText(b *strings.Builder, text string) {
|
||||
text = cleanWebText(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
last := b.String()[b.Len()-1]
|
||||
if last != '\n' && last != ' ' {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
|
||||
func writeBlockBreak(b *strings.Builder) {
|
||||
if b.Len() == 0 {
|
||||
return
|
||||
}
|
||||
text := b.String()
|
||||
if strings.HasSuffix(text, "\n\n") {
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(text, "\n") {
|
||||
b.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
func skipHTMLNode(n *html.Node) bool {
|
||||
if n.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
switch n.Data {
|
||||
case "script", "style", "noscript", "svg", "canvas", "template":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func textContent(n *html.Node) string {
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil || skipHTMLNode(node) {
|
||||
return
|
||||
}
|
||||
if node.Type == html.TextNode {
|
||||
b.WriteString(node.Data)
|
||||
b.WriteByte(' ')
|
||||
return
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return cleanWebText(b.String())
|
||||
}
|
||||
|
||||
func hasClass(n *html.Node, class string) bool {
|
||||
for _, item := range strings.Fields(attr(n, "class")) {
|
||||
if item == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attr(n *html.Node, name string) string {
|
||||
for _, item := range n.Attr {
|
||||
if item.Key == name {
|
||||
return item.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveURL(baseURL, raw string) string {
|
||||
ref, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
func cleanWebText(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func normalizeMarkdownText(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
blank := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
if !blank && len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
blank = true
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
blank = false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinSearchTool(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
if got := r.URL.Query().Get("q"); got != "agentu" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`
|
||||
<html><body>
|
||||
<div class="result">
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fagentu">Agentu release</a>
|
||||
<a class="result__snippet">Agentu adds built-in web tools.</a>
|
||||
</div>
|
||||
</body></html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := NewBuiltinSearchToolWithBaseURL(server.URL, 5, server.Client())
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"query":"agentu"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Web search results", "Agentu release", "https://example.com/agentu", "Agentu adds built-in web tools"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchTool(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`
|
||||
<html>
|
||||
<head><title>Agentu</title><script>hidden()</script></head>
|
||||
<body>
|
||||
<h1>Agentu</h1>
|
||||
<p>Built-in fetch reads HTML.</p>
|
||||
<ul><li>Search</li><li>Fetch</li></ul>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := NewBuiltinFetchTool(server.Client())
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"url":"`+server.URL+`"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Fetched web content", server.URL, "# Agentu", "Built-in fetch reads HTML", "- Search", "- Fetch"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "hidden") {
|
||||
t.Fatalf("script content should be ignored:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchToolAcceptsURLsWithoutScheme(t *testing.T) {
|
||||
args, err := parseWebFetchArgs(json.RawMessage(`{"url":"example.com/page"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if args.URL != "https://example.com/page" {
|
||||
t.Fatalf("url = %q", args.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinFetchToolFormatsFailedResults(t *testing.T) {
|
||||
response := webFetchResponse{
|
||||
FailedResults: []webFetchFailed{{URL: "https://example.com", Error: "blocked"}},
|
||||
}
|
||||
out := formatWebFetchResults("https://example.com", response)
|
||||
for _, want := range []string{"No readable web content", "Fetch failed", "blocked"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user