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:
loveuer
2026-06-23 09:59:24 +08:00
parent 86f5ca4aef
commit 8dbcb1147f
7 changed files with 747 additions and 3 deletions
+10
View File
@@ -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`
+2
View File
@@ -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{
+1 -1
View File
@@ -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
View File
@@ -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 {
+30
View File
@@ -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)
}
}
}
+597
View File
@@ -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"))
}
+96
View File
@@ -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)
}
}
}