first commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// This file implements the webfetch tool (US-012, #128): fetch a URL and return
|
||||
// its main text as simplified Markdown. pi has no such tool; this mirrors Claude
|
||||
// Code's WebFetch. Safety properties required by the issue:
|
||||
//
|
||||
// - HTTP URLs are upgraded to HTTPS before the request.
|
||||
// - Cross-origin redirects are NOT followed automatically; the redirect target
|
||||
// is returned to the caller (the model) so it can decide whether to fetch it.
|
||||
// - A request timeout and a response-body size cap bound the work.
|
||||
// - A failed fetch (timeout, non-2xx, unreachable) degrades to a structured
|
||||
// error result, never a panic.
|
||||
//
|
||||
// The optional "prompt" argument is accepted and echoed back in the result
|
||||
// framing so the model keeps its intent alongside the fetched content; the tool
|
||||
// does not itself call a model to summarize (that is the agent loop's job).
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// webFetchTimeout bounds a single fetch. webFetchMaxBytes caps how much of the
|
||||
// response body is read (protects the model's context and memory from huge
|
||||
// pages). webFetchMaxMarkdown caps the rendered Markdown length.
|
||||
const (
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchMaxBytes = 5 * 1024 * 1024
|
||||
webFetchMaxMarkdown = 100 * 1024
|
||||
)
|
||||
|
||||
// WebFetchTool fetches a URL and returns its text as simplified Markdown. The
|
||||
// zero value is usable; Client defaults to a redirect-blocking http.Client with
|
||||
// webFetchTimeout.
|
||||
type WebFetchTool struct {
|
||||
// Client performs the HTTP request. When nil, a default client is built that
|
||||
// blocks cross-origin redirects and enforces webFetchTimeout. Injected for
|
||||
// tests so a fake transport can serve canned responses.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// webFetchArgs is the decoded argument shape for WebFetchTool.
|
||||
type webFetchArgs struct {
|
||||
// URL is the page to fetch. An http:// URL is upgraded to https://.
|
||||
URL string `json:"url"`
|
||||
// Prompt is an optional instruction describing what the caller wants from the
|
||||
// page; it is echoed into the result framing, not acted on by the tool.
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *WebFetchTool) Name() string { return "webfetch" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *WebFetchTool) Description() string {
|
||||
return "Fetch a URL and return its main text content as simplified Markdown. " +
|
||||
"HTTP URLs are upgraded to HTTPS. Cross-origin redirects are not followed; " +
|
||||
"the redirect target is returned so you can fetch it explicitly. Use the " +
|
||||
"optional prompt to note what you are looking for on the page."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *WebFetchTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch. http:// is upgraded to https://."},
|
||||
"prompt": {"type": "string", "description": "Optional: what to extract or look for on the page."}
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. A fetch has no local side effects and is
|
||||
// safe to run alongside other reads → parallel.
|
||||
func (t *WebFetchTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// errRedirectBlocked is returned by the client's CheckRedirect to stop a
|
||||
// cross-origin redirect; the target is carried so Execute can report it.
|
||||
type errRedirectBlocked struct{ target string }
|
||||
|
||||
func (e *errRedirectBlocked) Error() string { return "cross-origin redirect blocked to " + e.target }
|
||||
|
||||
// newWebFetchClient builds the default redirect-blocking client. A redirect is
|
||||
// allowed only when it stays on the same host (scheme+host); a cross-origin hop
|
||||
// stops with errRedirectBlocked carrying the target URL.
|
||||
func newWebFetchClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: webFetchTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
orig := via[0].URL
|
||||
if req.URL.Host != orig.Host || req.URL.Scheme != orig.Scheme {
|
||||
return &errRedirectBlocked{target: req.URL.String()}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. Fetch failures are encoded as error results (the
|
||||
// returned Go error is always nil), matching the file tools' contract.
|
||||
func (t *WebFetchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[webFetchArgs](args, "webfetch")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
raw := strings.TrimSpace(a.URL)
|
||||
if raw == "" {
|
||||
return errorResult("webfetch: url is required"), nil
|
||||
}
|
||||
|
||||
target, err := normalizeFetchURL(raw)
|
||||
if err != nil {
|
||||
return errorResult("webfetch: " + err.Error()), nil
|
||||
}
|
||||
|
||||
client := t.Client
|
||||
if client == nil {
|
||||
client = newWebFetchClient()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return errorResult("webfetch: " + err.Error()), nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "pigo-webfetch/1.0")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// A blocked cross-origin redirect is reported specially so the model can
|
||||
// choose to fetch the target explicitly. errors.As unwraps the *url.Error
|
||||
// http.Client wraps CheckRedirect failures in.
|
||||
var blocked *errRedirectBlocked
|
||||
if errors.As(err, &blocked) {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
fmt.Sprintf("webfetch: cross-origin redirect not followed.\nTarget: %s\nFetch it explicitly if you want its content.", blocked.target))},
|
||||
Details: map[string]any{"redirect": blocked.target, "followed": false},
|
||||
}, nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("webfetch: request failed: %v", err)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return errorResult(fmt.Sprintf("webfetch: %s returned HTTP %d %s", target, resp.StatusCode, http.StatusText(resp.StatusCode))), nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, webFetchMaxBytes))
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("webfetch: reading response body: %v", err)), nil
|
||||
}
|
||||
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
var text string
|
||||
if strings.Contains(ctype, "html") || looksLikeHTML(body) {
|
||||
text = htmlToMarkdown(body)
|
||||
} else {
|
||||
text = string(body)
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
truncated := false
|
||||
if len(text) > webFetchMaxMarkdown {
|
||||
text = text[:webFetchMaxMarkdown]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Fetched %s (HTTP %d)\n", target, resp.StatusCode)
|
||||
if a.Prompt != "" {
|
||||
fmt.Fprintf(&b, "Prompt: %s\n", a.Prompt)
|
||||
}
|
||||
if truncated {
|
||||
b.WriteString("(content truncated)\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(text)
|
||||
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(b.String())},
|
||||
Details: map[string]any{"url": target, "status": resp.StatusCode, "truncated": truncated},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeFetchURL parses raw, upgrades an http scheme to https, and rejects
|
||||
// anything that is not an absolute http(s) URL with a host.
|
||||
func normalizeFetchURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid url: %v", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
u.Scheme = "https" // upgrade
|
||||
case "https":
|
||||
// ok
|
||||
case "":
|
||||
return "", fmt.Errorf("url must be absolute with an http(s) scheme: %q", raw)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported url scheme %q (want http or https)", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("url has no host: %q", raw)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// looksLikeHTML sniffs whether body begins with an HTML marker, used when the
|
||||
// server omits or mislabels Content-Type.
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := strings.ToLower(strings.TrimSpace(string(body[:min(512, len(body))])))
|
||||
return strings.HasPrefix(head, "<!doctype html") || strings.HasPrefix(head, "<html") || strings.Contains(head, "<body")
|
||||
}
|
||||
Reference in New Issue
Block a user