/ internal / tools / http.go
http.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"io"
  8  	"net/http"
  9  	"net/url"
 10  	"strings"
 11  	"time"
 12  
 13  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 14  )
 15  
 16  type HTTPTool struct{}
 17  
 18  type httpArgs struct {
 19  	URL     string            `json:"url"`
 20  	Method  string            `json:"method,omitempty"`
 21  	Headers map[string]string `json:"headers,omitempty"`
 22  	Body    string            `json:"body,omitempty"`
 23  	Timeout int               `json:"timeout,omitempty"`
 24  }
 25  
 26  func (t *HTTPTool) Info() agent.ToolInfo {
 27  	return agent.ToolInfo{
 28  		Name:        "http",
 29  		Description: "Make an HTTP request. Returns status code, response headers, and body (truncated to 10KB).",
 30  		Parameters: map[string]any{
 31  			"type": "object",
 32  			"properties": map[string]any{
 33  				"url":     map[string]any{"type": "string", "description": "Request URL"},
 34  				"method":  map[string]any{"type": "string", "description": "HTTP method (default: GET)"},
 35  				"headers": map[string]any{"type": "object", "description": "Request headers as key-value pairs"},
 36  				"body":    map[string]any{"type": "string", "description": "Request body"},
 37  				"timeout": map[string]any{"type": "integer", "description": "Timeout in seconds (default: 30)"},
 38  			},
 39  		},
 40  		Required: []string{"url"},
 41  	}
 42  }
 43  
 44  func (t *HTTPTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 45  	var args httpArgs
 46  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 47  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
 48  	}
 49  
 50  	method := args.Method
 51  	if method == "" {
 52  		method = "GET"
 53  	}
 54  	method = strings.ToUpper(method)
 55  
 56  	timeout := 30 * time.Second
 57  	if args.Timeout > 0 {
 58  		timeout = time.Duration(args.Timeout) * time.Second
 59  	}
 60  
 61  	ctx, cancel := context.WithTimeout(ctx, timeout)
 62  	defer cancel()
 63  
 64  	var bodyReader io.Reader
 65  	if args.Body != "" {
 66  		bodyReader = strings.NewReader(args.Body)
 67  	}
 68  
 69  	req, err := http.NewRequestWithContext(ctx, method, args.URL, bodyReader)
 70  	if err != nil {
 71  		return agent.ValidationError(fmt.Sprintf("error creating request: %v", err)), nil
 72  	}
 73  
 74  	for k, v := range args.Headers {
 75  		req.Header.Set(k, v)
 76  	}
 77  
 78  	client := &http.Client{Timeout: timeout}
 79  	resp, err := client.Do(req)
 80  	if err != nil {
 81  		return agent.TransientError(fmt.Sprintf("request failed: %v", err)), nil
 82  	}
 83  	defer resp.Body.Close()
 84  
 85  	body, err := io.ReadAll(io.LimitReader(resp.Body, 10240))
 86  	if err != nil {
 87  		return agent.TransientError(fmt.Sprintf("error reading response body: %v", err)), nil
 88  	}
 89  
 90  	var sb strings.Builder
 91  	fmt.Fprintf(&sb, "Status: %d %s\n\nHeaders:\n", resp.StatusCode, resp.Status)
 92  	for k, vals := range resp.Header {
 93  		for _, v := range vals {
 94  			fmt.Fprintf(&sb, "  %s: %s\n", k, v)
 95  		}
 96  	}
 97  	fmt.Fprintf(&sb, "\nBody:\n%s", string(body))
 98  
 99  	// Method-aware IsError. Status is always visible in the "Status: ..." line
100  	// of the body, so the model can branch on any status regardless of IsError.
101  	//   5xx                      → always IsError (server failure)
102  	//   4xx on mutations
103  	//   (POST/PUT/PATCH/DELETE)  → IsError (validation / auth / routing bug)
104  	//   4xx on reads
105  	//   (GET/HEAD/OPTIONS/other) → IsError EXCEPT 404 and 410, which are
106  	//                              polling-friendly ("not yet" / "gone").
107  	//                              401/403/429 and other 4xx stay IsError:
108  	//                              auth failures and rate-limits are real
109  	//                              operational errors that the SameToolError
110  	//                              loop detector should see.
111  	isError := false
112  	switch {
113  	case resp.StatusCode >= 500:
114  		isError = true
115  	case resp.StatusCode >= 400:
116  		switch method {
117  		case "POST", "PUT", "PATCH", "DELETE":
118  			isError = true
119  		default:
120  			// Reads: only 404/410 are polling-exempt.
121  			if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusGone {
122  				isError = true
123  			}
124  		}
125  	}
126  	return agent.ToolResult{Content: sb.String(), IsError: isError}, nil
127  }
128  
129  func (t *HTTPTool) RequiresApproval() bool { return true }
130  
131  func (t *HTTPTool) IsReadOnlyCall(string) bool { return false }
132  
133  func (t *HTTPTool) IsSafeArgs(argsJSON string) bool {
134  	var args httpArgs
135  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
136  		return false
137  	}
138  
139  	method := strings.ToUpper(args.Method)
140  	if method == "" {
141  		method = "GET"
142  	}
143  	if method != "GET" {
144  		return false
145  	}
146  
147  	parsed, err := url.Parse(args.URL)
148  	if err != nil {
149  		return false
150  	}
151  
152  	host := parsed.Hostname()
153  	return host == "localhost" || host == "127.0.0.1"
154  }