agent/pkg/agentic/transport.go
Virgil 3d2fa035a9 refactor(agentic): migrate workflow helpers to core.Result
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 07:30:42 +00:00

370 lines
11 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
// HTTP transport for Core API streams.
// This is the ONE file in core/agent that imports net/http.
// All other files use the exported helpers: HTTPGet, HTTPPost, HTTPCall.
package agentic
import (
"context"
"net/http"
"time"
core "dappco.re/go/core"
)
// defaultClient is the shared HTTP client for all transport calls.
var defaultClient = &http.Client{Timeout: 30 * time.Second}
// httpStream implements core.Stream over HTTP request/response.
type httpStream struct {
client *http.Client
url string
token string
method string
response []byte
}
// Send issues the configured HTTP request and caches the response body for Receive.
//
// stream := &httpStream{client: defaultClient, url: "https://forge.lthn.ai/api/v1/version", method: "GET"}
// _ = stream.Send(nil)
func (s *httpStream) Send(data []byte) error {
req, err := http.NewRequestWithContext(context.Background(), s.method, s.url, core.NewReader(string(data)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if s.token != "" {
req.Header.Set("Authorization", core.Concat("token ", s.token))
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
r := core.ReadAll(resp.Body)
if !r.OK {
return core.E("httpStream.Send", "failed to read response", nil)
}
s.response = []byte(r.Value.(string))
return nil
}
// Receive returns the cached response body from the last Send call.
//
// stream := &httpStream{response: []byte(`{"ok":true}`)}
// data, _ := stream.Receive()
// _ = data
func (s *httpStream) Receive() ([]byte, error) {
return s.response, nil
}
// Close satisfies core.Stream for one-shot HTTP requests.
//
// stream := &httpStream{}
// _ = stream.Close()
func (s *httpStream) Close() error {
return nil
}
// RegisterHTTPTransport registers the HTTP/HTTPS protocol handler with Core API.
//
// agentic.RegisterHTTPTransport(c)
func RegisterHTTPTransport(c *core.Core) {
factory := func(handle *core.DriveHandle) (core.Stream, error) {
token := handle.Options.String("token")
return &httpStream{
client: defaultClient,
url: handle.Transport,
token: token,
method: "POST",
}, nil
}
c.API().RegisterProtocol("http", factory)
c.API().RegisterProtocol("https", factory)
}
// --- REST helpers — all HTTP in core/agent routes through these ---
// HTTPGet performs a GET request. Returns Result{Value: string (response body), OK: bool}.
// Auth is "token {token}" for Forge, "Bearer {token}" for Brain.
//
// r := agentic.HTTPGet(ctx, "https://forge.lthn.ai/api/v1/repos", "my-token", "token")
func HTTPGet(ctx context.Context, url, token, authScheme string) core.Result {
return httpDo(ctx, "GET", url, "", token, authScheme)
}
// HTTPPost performs a POST request with a JSON body. Returns Result{Value: string, OK: bool}.
//
// r := agentic.HTTPPost(ctx, url, core.JSONMarshalString(payload), token, "token")
func HTTPPost(ctx context.Context, url, body, token, authScheme string) core.Result {
return httpDo(ctx, "POST", url, body, token, authScheme)
}
// HTTPPatch performs a PATCH request with a JSON body.
//
// r := agentic.HTTPPatch(ctx, url, body, token, "token")
func HTTPPatch(ctx context.Context, url, body, token, authScheme string) core.Result {
return httpDo(ctx, "PATCH", url, body, token, authScheme)
}
// HTTPDelete performs a DELETE request.
//
// r := agentic.HTTPDelete(ctx, url, body, token, "Bearer")
func HTTPDelete(ctx context.Context, url, body, token, authScheme string) core.Result {
return httpDo(ctx, "DELETE", url, body, token, authScheme)
}
// HTTPDo performs an HTTP request with the specified method.
//
// r := agentic.HTTPDo(ctx, "PUT", url, body, token, "token")
func HTTPDo(ctx context.Context, method, url, body, token, authScheme string) core.Result {
return httpDo(ctx, method, url, body, token, authScheme)
}
// --- Drive-aware REST helpers — route through c.Drive() for endpoint resolution ---
// DriveGet performs a GET request using a named Drive endpoint.
// Reads base URL and token from the Drive handle registered in Core.
//
// r := DriveGet(c, "forge", "/api/v1/repos/core/go-io", "token")
func DriveGet(c *core.Core, drive, path, authScheme string) core.Result {
base, token := driveEndpoint(c, drive)
if base == "" {
return core.Result{Value: core.E("DriveGet", core.Concat("drive not found: ", drive), nil), OK: false}
}
return httpDo(context.Background(), "GET", core.Concat(base, path), "", token, authScheme)
}
// DrivePost performs a POST request using a named Drive endpoint.
//
// r := DrivePost(c, "forge", "/api/v1/repos/core/go-io/issues", body, "token")
func DrivePost(c *core.Core, drive, path, body, authScheme string) core.Result {
base, token := driveEndpoint(c, drive)
if base == "" {
return core.Result{Value: core.E("DrivePost", core.Concat("drive not found: ", drive), nil), OK: false}
}
return httpDo(context.Background(), "POST", core.Concat(base, path), body, token, authScheme)
}
// DriveDo performs an HTTP request using a named Drive endpoint.
//
// r := DriveDo(c, "forge", "PATCH", "/api/v1/repos/core/go-io/pulls/5", body, "token")
func DriveDo(c *core.Core, drive, method, path, body, authScheme string) core.Result {
base, token := driveEndpoint(c, drive)
if base == "" {
return core.Result{Value: core.E("DriveDo", core.Concat("drive not found: ", drive), nil), OK: false}
}
return httpDo(context.Background(), method, core.Concat(base, path), body, token, authScheme)
}
// driveEndpoint reads base URL and token from a named Drive handle.
func driveEndpoint(c *core.Core, name string) (base, token string) {
r := c.Drive().Get(name)
if !r.OK {
return "", ""
}
h := r.Value.(*core.DriveHandle)
return h.Transport, h.Options.String("token")
}
// httpDo is the single HTTP execution point. Every HTTP call in core/agent routes here.
func httpDo(ctx context.Context, method, url, body, token, authScheme string) core.Result {
var req *http.Request
var err error
if body != "" {
req, err = http.NewRequestWithContext(ctx, method, url, core.NewReader(body))
} else {
req, err = http.NewRequestWithContext(ctx, method, url, nil)
}
if err != nil {
return core.Result{OK: false}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if token != "" {
if authScheme == "" {
authScheme = "token"
}
req.Header.Set("Authorization", core.Concat(authScheme, " ", token))
}
resp, err := defaultClient.Do(req)
if err != nil {
return core.Result{OK: false}
}
r := core.ReadAll(resp.Body)
if !r.OK {
return core.Result{OK: false}
}
return core.Result{Value: r.Value.(string), OK: resp.StatusCode < 400}
}
// --- MCP Streamable HTTP Transport ---
// mcpInitialize performs the MCP initialise handshake over Streamable HTTP.
// Returns the session ID from the Mcp-Session-Id header.
func mcpInitialize(ctx context.Context, url, token string) (string, error) {
result := mcpInitializeResult(ctx, url, token)
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
return "", core.E("mcpInitialize", "failed", nil)
}
return "", err
}
sessionID, ok := result.Value.(string)
if !ok {
return "", core.E("mcpInitialize", "invalid session id result", nil)
}
return sessionID, nil
}
func mcpInitializeResult(ctx context.Context, url, token string) core.Result {
initReq := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "core-agent-remote",
"version": "0.2.0",
},
},
}
body := core.JSONMarshalString(initReq)
req, err := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(body))
if err != nil {
return core.Result{Value: core.E("mcpInitialize", "create request", nil), OK: false}
}
mcpHeaders(req, token, "")
resp, err := defaultClient.Do(req)
if err != nil {
return core.Result{Value: core.E("mcpInitialize", "request failed", nil), OK: false}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return core.Result{Value: core.E("mcpInitialize", core.Sprintf("HTTP %d", resp.StatusCode), nil), OK: false}
}
sessionID := resp.Header.Get("Mcp-Session-Id")
// Drain SSE response
drainSSE(resp)
// Send initialised notification
notif := core.JSONMarshalString(map[string]any{
"jsonrpc": "2.0",
"method": "notifications/initialized",
})
notifReq, _ := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(notif))
mcpHeaders(notifReq, token, sessionID)
notifResp, err := defaultClient.Do(notifReq)
if err == nil {
notifResp.Body.Close()
}
return core.Result{Value: sessionID, OK: true}
}
// mcpCall sends a JSON-RPC request and returns the parsed response.
func mcpCall(ctx context.Context, url, token, sessionID string, body []byte) ([]byte, error) {
result := mcpCallResult(ctx, url, token, sessionID, body)
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
return nil, core.E("mcpCall", "failed", nil)
}
return nil, err
}
data, ok := result.Value.([]byte)
if !ok {
return nil, core.E("mcpCall", "invalid call result", nil)
}
return data, nil
}
func mcpCallResult(ctx context.Context, url, token, sessionID string, body []byte) core.Result {
req, err := http.NewRequestWithContext(ctx, "POST", url, core.NewReader(string(body)))
if err != nil {
return core.Result{Value: core.E("mcpCall", "create request", nil), OK: false}
}
mcpHeaders(req, token, sessionID)
resp, err := defaultClient.Do(req)
if err != nil {
return core.Result{Value: core.E("mcpCall", "request failed", nil), OK: false}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return core.Result{Value: core.E("mcpCall", core.Sprintf("HTTP %d", resp.StatusCode), nil), OK: false}
}
return readSSEDataResult(resp)
}
// readSSEData reads an SSE response and extracts JSON from data: lines.
func readSSEData(resp *http.Response) ([]byte, error) {
result := readSSEDataResult(resp)
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
return nil, core.E("readSSEData", "failed", nil)
}
return nil, err
}
data, ok := result.Value.([]byte)
if !ok {
return nil, core.E("readSSEData", "invalid data result", nil)
}
return data, nil
}
// readSSEDataResult parses an SSE response and extracts the first data: payload as core.Result.
func readSSEDataResult(resp *http.Response) core.Result {
r := core.ReadAll(resp.Body)
if !r.OK {
err, _ := r.Value.(error)
if err == nil {
return core.Result{Value: core.E("readSSEData", "failed to read response", nil), OK: false}
}
return core.Result{Value: core.E("readSSEData", "failed to read response", err), OK: false}
}
for _, line := range core.Split(r.Value.(string), "\n") {
if core.HasPrefix(line, "data: ") {
return core.Result{Value: []byte(core.TrimPrefix(line, "data: ")), OK: true}
}
}
return core.Result{Value: core.E("readSSEData", "no data in SSE response", nil), OK: false}
}
// mcpHeaders applies standard MCP HTTP headers.
func mcpHeaders(req *http.Request, token, sessionID string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if token != "" {
req.Header.Set("Authorization", core.Concat("Bearer ", token))
}
if sessionID != "" {
req.Header.Set("Mcp-Session-Id", sessionID)
}
}
// drainSSE reads and discards an SSE response body.
func drainSSE(resp *http.Response) {
core.ReadAll(resp.Body)
}