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`.
|
- Multiple providers from `~/.agentu/config.yaml`.
|
||||||
- Session-only provider, model, and thinking-level switching.
|
- Session-only provider, model, and thinking-level switching.
|
||||||
- Read-only project tools enabled by default.
|
- Read-only project tools enabled by default.
|
||||||
|
- Built-in web search and URL fetching.
|
||||||
- `--yolo` mode for file writes and shell execution.
|
- `--yolo` mode for file writes and shell execution.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -58,6 +59,10 @@ agent:
|
|||||||
tool_timeout: 30s
|
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
|
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
|
first provider by name. Use `/model provider <name>` to switch providers for the
|
||||||
current session.
|
current session.
|
||||||
@@ -112,6 +117,11 @@ Read-only tools are available by default:
|
|||||||
- `file_list`
|
- `file_list`
|
||||||
- `file_search`
|
- `file_search`
|
||||||
|
|
||||||
|
Web tools are also available in read-only mode:
|
||||||
|
|
||||||
|
- `web_search`
|
||||||
|
- `web_fetch`
|
||||||
|
|
||||||
`--yolo` additionally enables:
|
`--yolo` additionally enables:
|
||||||
|
|
||||||
- `file_write`
|
- `file_write`
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ func run() error {
|
|||||||
} else {
|
} else {
|
||||||
registry = tools.ReadOnlyBuiltins(cfg.Agent.WorkingDir)
|
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))
|
systemPrompt := strings.TrimSpace(cfg.Agent.SystemPrompt + "\n\n" + tools.AgentInstructions(cfg.Agent.WorkingDir, *yolo))
|
||||||
|
|
||||||
assistant := agent.New(agent.Options{
|
assistant := agent.New(agent.Options{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/glamour v1.0.0
|
github.com/charmbracelet/glamour v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
|
golang.org/x/net v0.38.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +40,6 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.13 // indirect
|
github.com/yuin/goldmark v1.7.13 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // 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/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/text v0.30.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 {
|
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 {
|
if yolo {
|
||||||
return fmt.Sprintf(`Local project context:
|
return fmt.Sprintf(`Local project context:
|
||||||
- The project working directory is %q.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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:
|
return fmt.Sprintf(`Local project context:
|
||||||
- The project working directory is %q.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 {
|
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) {
|
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||||
registry := Builtins(t.TempDir())
|
registry := Builtins(t.TempDir())
|
||||||
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
|
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