Skip to content

Commit ea4fb6f

Browse files
authored
Merge pull request #54 from LAA-Software-Engineering/issue/20-http-tool
feat(tools): HTTP tool runtime (MVP, closes #20)
2 parents 88f38a5 + 3ee00d7 commit ea4fb6f

6 files changed

Lines changed: 445 additions & 3 deletions

File tree

internal/tools/doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Package tools defines tool registries and integrations (MCP, HTTP, native).
22
//
3-
// [Registry] resolves tool.<name>.<operation> uses strings and dispatches MVP native, mock, and MCP stdio tools.
3+
// [Registry] resolves tool.<name>.<operation> uses strings and dispatches MVP native, mock, MCP stdio, and HTTP tools.
44
// Responses use [ToolCallResponse] with output + meta per §13.2.
55
package tools

internal/tools/http/client.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package httptool
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"strings"
12+
"time"
13+
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/models"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
16+
)
17+
18+
// ExecMeta is timing/cost metadata for an HTTP tool call (§13.2 placeholders).
19+
type ExecMeta struct {
20+
DurationMs int64
21+
CostUSD float64
22+
}
23+
24+
// clientError is a 4xx response (not retried).
25+
type clientError struct {
26+
code int
27+
msg string
28+
}
29+
30+
func (e *clientError) Error() string {
31+
return fmt.Sprintf("httptool: HTTP %d %s", e.code, e.msg)
32+
}
33+
34+
// serverHTTPError is a 5xx response (retried when policy allows).
35+
type serverHTTPError struct {
36+
code int
37+
body string
38+
}
39+
40+
func (e *serverHTTPError) Error() string {
41+
return fmt.Sprintf("httptool: HTTP %d", e.code)
42+
}
43+
44+
// Execute performs one logical HTTP tool call, including optional retries on transport/5xx errors.
45+
// client may be nil to use http.DefaultClient (tests should pass srv.Client()).
46+
func Execute(ctx context.Context, cfg *spec.ToolHTTP, retry *spec.ToolRetry, operation string, with map[string]any, client *http.Client) (map[string]any, ExecMeta, error) {
47+
if cfg == nil {
48+
return nil, ExecMeta{}, errors.New("httptool: nil http config")
49+
}
50+
base := strings.TrimSpace(cfg.BaseURL)
51+
if base == "" {
52+
return nil, ExecMeta{}, errors.New("httptool: empty baseUrl")
53+
}
54+
method, path, err := parseOperation(operation)
55+
if err != nil {
56+
return nil, ExecMeta{}, err
57+
}
58+
urlStr := joinURL(base, path)
59+
60+
attempts := 1
61+
if retry != nil && retry.MaxAttempts > 0 {
62+
attempts = retry.MaxAttempts
63+
}
64+
backoff := ""
65+
if retry != nil {
66+
backoff = retry.Backoff
67+
}
68+
if client == nil {
69+
client = http.DefaultClient
70+
}
71+
72+
start := time.Now()
73+
var lastErr error
74+
for attempt := 0; attempt < attempts; attempt++ {
75+
if attempt > 0 {
76+
sleepBackoff(ctx, attempt, backoff)
77+
}
78+
out, err := doRequest(ctx, client, method, urlStr, cfg.Headers, with)
79+
if err == nil {
80+
return out, ExecMeta{DurationMs: time.Since(start).Milliseconds(), CostUSD: 0}, nil
81+
}
82+
lastErr = err
83+
if !retryableHTTP(err) {
84+
break
85+
}
86+
}
87+
return nil, ExecMeta{DurationMs: time.Since(start).Milliseconds(), CostUSD: 0}, lastErr
88+
}
89+
90+
func parseOperation(operation string) (method, path string, err error) {
91+
operation = strings.TrimSpace(operation)
92+
if operation == "" {
93+
return "", "", fmt.Errorf("httptool: empty operation")
94+
}
95+
parts := strings.Split(operation, ".")
96+
verbs := map[string]string{
97+
"get": "GET", "post": "POST", "put": "PUT", "delete": "DELETE", "patch": "PATCH",
98+
}
99+
if m, ok := verbs[strings.ToLower(parts[0])]; ok {
100+
if len(parts) == 1 {
101+
return m, "/", nil
102+
}
103+
return m, "/" + strings.Join(parts[1:], "/"), nil
104+
}
105+
return "GET", "/" + strings.Join(parts, "/"), nil
106+
}
107+
108+
func joinURL(base, path string) string {
109+
base = strings.TrimRight(strings.TrimSpace(base), "/")
110+
if path == "" {
111+
return base + "/"
112+
}
113+
if !strings.HasPrefix(path, "/") {
114+
path = "/" + path
115+
}
116+
return base + path
117+
}
118+
119+
func resolveHeaders(h map[string]string) (http.Header, error) {
120+
hdr := make(http.Header)
121+
if h == nil {
122+
return hdr, nil
123+
}
124+
for k, v := range h {
125+
resolved, err := resolveHeaderValue(v)
126+
if err != nil {
127+
return nil, fmt.Errorf("httptool: header %q: %w", k, err)
128+
}
129+
hdr.Set(k, resolved)
130+
}
131+
return hdr, nil
132+
}
133+
134+
func resolveHeaderValue(v string) (string, error) {
135+
v = strings.TrimSpace(v)
136+
if strings.HasPrefix(v, "env:") {
137+
return models.ResolveAPIKeyFrom(v)
138+
}
139+
return v, nil
140+
}
141+
142+
func doRequest(ctx context.Context, cli *http.Client, method, urlStr string, headers map[string]string, with map[string]any) (map[string]any, error) {
143+
hdr, err := resolveHeaders(headers)
144+
if err != nil {
145+
return nil, err
146+
}
147+
148+
var body io.Reader
149+
switch method {
150+
case "POST", "PUT", "PATCH":
151+
if with == nil {
152+
with = map[string]any{}
153+
}
154+
b, err := json.Marshal(with)
155+
if err != nil {
156+
return nil, err
157+
}
158+
body = bytes.NewReader(b)
159+
if hdr.Get("Content-Type") == "" {
160+
hdr.Set("Content-Type", "application/json")
161+
}
162+
}
163+
164+
req, err := http.NewRequestWithContext(ctx, method, urlStr, body)
165+
if err != nil {
166+
return nil, err
167+
}
168+
req.Header = hdr
169+
170+
resp, err := cli.Do(req)
171+
if err != nil {
172+
return nil, err
173+
}
174+
defer resp.Body.Close()
175+
176+
b, err := io.ReadAll(resp.Body)
177+
if err != nil {
178+
return nil, err
179+
}
180+
181+
if resp.StatusCode >= 500 {
182+
return nil, &serverHTTPError{code: resp.StatusCode, body: string(b)}
183+
}
184+
if resp.StatusCode >= 400 {
185+
return nil, &clientError{code: resp.StatusCode, msg: truncateBody(b, 512)}
186+
}
187+
188+
return decodeResponseBody(b, resp.Header.Get("Content-Type"))
189+
}
190+
191+
func decodeResponseBody(b []byte, contentType string) (map[string]any, error) {
192+
ct := strings.ToLower(contentType)
193+
if len(b) == 0 {
194+
return map[string]any{}, nil
195+
}
196+
if strings.Contains(ct, "application/json") || b[0] == '{' || b[0] == '[' {
197+
var obj map[string]any
198+
if json.Unmarshal(b, &obj) == nil {
199+
return obj, nil
200+
}
201+
var arr []any
202+
if json.Unmarshal(b, &arr) == nil {
203+
return map[string]any{"items": arr}, nil
204+
}
205+
}
206+
return map[string]any{"body": string(b)}, nil
207+
}
208+
209+
func truncateBody(b []byte, n int) string {
210+
s := string(b)
211+
if len(s) <= n {
212+
return s
213+
}
214+
return s[:n] + "..."
215+
}
216+
217+
func retryableHTTP(err error) bool {
218+
if err == nil {
219+
return false
220+
}
221+
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
222+
return false
223+
}
224+
var ce *clientError
225+
if errors.As(err, &ce) {
226+
return false
227+
}
228+
var se *serverHTTPError
229+
if errors.As(err, &se) {
230+
return true
231+
}
232+
return true
233+
}
234+
235+
func sleepBackoff(ctx context.Context, attempt int, kind string) {
236+
if attempt <= 0 {
237+
return
238+
}
239+
var d time.Duration
240+
switch strings.ToLower(strings.TrimSpace(kind)) {
241+
case "exponential":
242+
shift := attempt
243+
if shift > 8 {
244+
shift = 8
245+
}
246+
d = time.Millisecond * time.Duration(50*(1<<shift))
247+
case "fixed":
248+
d = 100 * time.Millisecond
249+
default:
250+
d = 50 * time.Millisecond
251+
}
252+
select {
253+
case <-ctx.Done():
254+
case <-time.After(d):
255+
}
256+
}

internal/tools/http/client_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package httptool
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
"testing"
9+
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
11+
)
12+
13+
func TestExecute_httptest_GET_success(t *testing.T) {
14+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
if r.Method != "GET" || r.URL.Path != "/ping" {
16+
t.Errorf("got %s %s", r.Method, r.URL.Path)
17+
http.NotFound(w, r)
18+
return
19+
}
20+
w.Header().Set("Content-Type", "application/json")
21+
_, _ = w.Write([]byte(`{"ok":true,"n":1}`))
22+
}))
23+
defer srv.Close()
24+
25+
out, meta, err := Execute(context.Background(), &spec.ToolHTTP{BaseURL: srv.URL}, nil, "get.ping", nil, srv.Client())
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
if meta.DurationMs < 0 {
30+
t.Fatalf("meta %+v", meta)
31+
}
32+
if out["ok"] != true || out["n"] != float64(1) {
33+
t.Fatalf("output %+v", out)
34+
}
35+
}
36+
37+
func TestExecute_header_envResolution(t *testing.T) {
38+
t.Setenv("HTTPTOOL_TEST_SECRET", "s3cr3t")
39+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+
if r.Header.Get("X-Auth") != "s3cr3t" {
41+
http.Error(w, "auth", http.StatusUnauthorized)
42+
return
43+
}
44+
w.Header().Set("Content-Type", "application/json")
45+
_, _ = w.Write([]byte(`{"auth":"ok"}`))
46+
}))
47+
defer srv.Close()
48+
49+
out, _, err := Execute(context.Background(), &spec.ToolHTTP{
50+
BaseURL: srv.URL,
51+
Headers: map[string]string{"X-Auth": "env:HTTPTOOL_TEST_SECRET"},
52+
}, nil, "get.data", nil, srv.Client())
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
if out["auth"] != "ok" {
57+
t.Fatalf("%+v", out)
58+
}
59+
}
60+
61+
func TestExecute_4xx_notRetried(t *testing.T) {
62+
var calls atomic.Int32
63+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+
calls.Add(1)
65+
http.Error(w, "missing", http.StatusNotFound)
66+
}))
67+
defer srv.Close()
68+
69+
_, _, err := Execute(context.Background(), &spec.ToolHTTP{BaseURL: srv.URL}, &spec.ToolRetry{
70+
MaxAttempts: 3,
71+
Backoff: "fixed",
72+
}, "get.missing", nil, srv.Client())
73+
if err == nil {
74+
t.Fatal("expected error")
75+
}
76+
if calls.Load() != 1 {
77+
t.Fatalf("want 1 HTTP request on 404, got %d", calls.Load())
78+
}
79+
}
80+
81+
func TestExecute_5xx_retried(t *testing.T) {
82+
var calls atomic.Int32
83+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
n := calls.Add(1)
85+
if n == 1 {
86+
w.WriteHeader(http.StatusServiceUnavailable)
87+
return
88+
}
89+
w.Header().Set("Content-Type", "application/json")
90+
_, _ = w.Write([]byte(`{"recovered":true}`))
91+
}))
92+
defer srv.Close()
93+
94+
out, _, err := Execute(context.Background(), &spec.ToolHTTP{BaseURL: srv.URL}, &spec.ToolRetry{
95+
MaxAttempts: 2,
96+
Backoff: "fixed",
97+
}, "get.stable", nil, srv.Client())
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
if calls.Load() != 2 {
102+
t.Fatalf("want 2 attempts, got %d", calls.Load())
103+
}
104+
if out["recovered"] != true {
105+
t.Fatalf("%+v", out)
106+
}
107+
}
108+
109+
func TestParseOperation_postPath(t *testing.T) {
110+
m, p, err := parseOperation("post.api.v1.items")
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
if m != "POST" || p != "/api/v1/items" {
115+
t.Fatalf("%s %s", m, p)
116+
}
117+
}

internal/tools/http/doc.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Package httptool runs Tool specs with type http (design doc §7.3, issue #20).
2+
package httptool
3+
4+
//
5+
// # Operation → HTTP mapping (MVP)
6+
//
7+
// The workflow "operation" string (after tool.<name>.) is split on ".":
8+
//
9+
// - If the first segment is get, post, put, delete, or patch (case-insensitive),
10+
// that becomes the HTTP method and the remaining segments form the path joined with "/"
11+
// (with a leading slash). Example: post.api.v1.items → POST /api/v1/items
12+
//
13+
// - Otherwise the method is GET and all segments form the path.
14+
// Example: health.live → GET /health/live
15+
//
16+
// baseUrl and path are concatenated (trailing slash on baseUrl is stripped).

0 commit comments

Comments
 (0)