agent/pkg/agentic/remote_client.go
Snider 90b03191b2 feat(agent): v0.2.0 — HTTP daemon, remote dispatch, review queue, verify+merge
Major additions:
- core-agent serve: persistent HTTP daemon with PID file, health check, registry
- agentic_dispatch_remote: dispatch tasks to remote agents (Charon) over MCP HTTP
- agentic_status_remote: check remote agent workspace status
- agentic_mirror: sync Forge repos to GitHub mirrors with file count limits
- agentic_review_queue: CodeRabbit/Codex review queue with rate-limit awareness
- verify.go: auto-verify (run tests) + auto-merge + retry with rebase + needs-review label
- monitor sync: checkin API integration for cross-agent repo sync
- PostToolUse inbox notification hook (check-notify.sh)

Dispatch improvements:
- --dangerously-skip-permissions (CLI flag changed)
- proc.CloseStdin() after spawn (Claude CLI stdin pipe fix)
- GOWORK=off in agent env and verify
- Exit code / BLOCKED.md / failure detection
- Monitor poke for instant notifications

New agent types:
- coderabbit: CodeRabbit CLI review (--plain --base)
- codex:review: OpenAI Codex review mode

Integrations:
- CODEX.md: OpenAI Codex conventions file
- Gemini extension: points at core-agent MCP (not Node server)
- Codex config: core-agent MCP server added
- GitHub webhook handler + CodeRabbit KPI tables (PHP)
- Forgejo provider for uptelligence webhooks
- Agent checkin endpoint for repo sync

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:45:04 +00:00

128 lines
3.4 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
coreerr "forge.lthn.ai/core/go-log"
)
// mcpInitialize performs the MCP initialize handshake over Streamable HTTP.
// Returns the session ID from the Mcp-Session-Id header.
func mcpInitialize(ctx context.Context, client *http.Client, url, token string) (string, error) {
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, _ := json.Marshal(initReq)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", coreerr.E("mcpInitialize", "create request", err)
}
setHeaders(req, token, "")
resp, err := client.Do(req)
if err != nil {
return "", coreerr.E("mcpInitialize", "request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", coreerr.E("mcpInitialize", fmt.Sprintf("HTTP %d", resp.StatusCode), nil)
}
sessionID := resp.Header.Get("Mcp-Session-Id")
// Drain the SSE response (we don't need the initialize result)
drainSSE(resp)
// Send initialized notification
notif := map[string]any{
"jsonrpc": "2.0",
"method": "notifications/initialized",
}
notifBody, _ := json.Marshal(notif)
notifReq, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(notifBody))
setHeaders(notifReq, token, sessionID)
notifResp, err := client.Do(notifReq)
if err == nil {
notifResp.Body.Close()
}
return sessionID, nil
}
// mcpCall sends a JSON-RPC request and returns the parsed response.
// Handles the SSE response format (text/event-stream with data: lines).
func mcpCall(ctx context.Context, client *http.Client, url, token, sessionID string, body []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, coreerr.E("mcpCall", "create request", err)
}
setHeaders(req, token, sessionID)
resp, err := client.Do(req)
if err != nil {
return nil, coreerr.E("mcpCall", "request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, coreerr.E("mcpCall", fmt.Sprintf("HTTP %d", resp.StatusCode), nil)
}
// Parse SSE response — extract data: lines
return readSSEData(resp)
}
// readSSEData reads an SSE response and extracts the JSON from data: lines.
func readSSEData(resp *http.Response) ([]byte, error) {
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
return []byte(strings.TrimPrefix(line, "data: ")), nil
}
}
return nil, coreerr.E("readSSEData", "no data in SSE response", nil)
}
// setHeaders applies standard MCP HTTP headers.
func setHeaders(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", "Bearer "+token)
}
if sessionID != "" {
req.Header.Set("Mcp-Session-Id", sessionID)
}
}
// drainSSE reads and discards an SSE response body.
func drainSSE(resp *http.Response) {
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
// Discard
}
}