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 }