iop/apps/edge/internal/input/a2a/server.go
toki b4124f0bd6 feat: CLI setup, edge/node transport refactor, and infrastructure updates
- Add CLI core setup for edge and node services
- Refactor edge transport layer (server, integration tests)
- Refactor node transport layer (parser, session, heartbeat, client)
- Add main_test.go files for edge and node commands
- Add input package for edge service
- Add go.work and go.work.sum for workspace support
- Update configs, docs, and project rules
2026-05-20 16:37:42 +09:00

303 lines
8 KiB
Go

package a2a
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"go.uber.org/zap"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
)
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
type Server struct {
cfg config.EdgeA2AConf
svc runService
tasks *TaskStore
logger *zap.Logger
server *http.Server
}
func NewServer(cfg config.EdgeA2AConf, svc runService, logger *zap.Logger) *Server {
if logger == nil {
logger = zap.NewNop()
}
return &Server{cfg: cfg, svc: svc, tasks: newTaskStore(), logger: logger}
}
func (s *Server) Enabled() bool {
return s != nil && s.cfg.Enabled
}
func (s *Server) Start(ctx context.Context) error {
if !s.Enabled() {
return nil
}
if s.cfg.Listen == "" {
s.cfg.Listen = "0.0.0.0:8081"
}
path := s.cfg.Path
if path == "" {
path = "/a2a"
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/agent.json", s.handleAgentCard)
mux.HandleFunc(path, s.withAuth(s.handleRPC))
s.server = &http.Server{
Addr: s.cfg.Listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", s.cfg.Listen)
if err != nil {
return fmt.Errorf("a2a server listen %s: %w", s.cfg.Listen, err)
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.server.Shutdown(shutdownCtx)
}()
go func() {
s.logger.Info("a2a server listening", zap.String("addr", s.cfg.Listen), zap.String("path", path))
if err := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Warn("a2a server exited", zap.Error(err))
}
}()
return nil
}
func (s *Server) Stop(ctx context.Context) error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}
// RPCHandlerForTest returns the auth-wrapped JSON-RPC handler for unit testing
// without starting a real listener.
func (s *Server) RPCHandlerForTest() http.HandlerFunc {
return s.withAuth(s.handleRPC)
}
func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.BearerToken != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + s.cfg.BearerToken
if auth != expected {
writeRPCError(w, nil, http.StatusUnauthorized, ErrCodeInvalidRequest, "unauthorized")
return
}
}
next(w, r)
}
}
func (s *Server) handleAgentCard(w http.ResponseWriter, _ *http.Request) {
card := map[string]any{
"name": "IOP Edge A2A Agent",
"description": "IOP Edge A2A input surface",
"url": fmt.Sprintf("http://%s%s", s.cfg.Listen, s.cfg.Path),
"version": "1.0.0",
"capabilities": map[string]any{
"streaming": false,
},
}
writeJSON(w, http.StatusOK, card)
}
func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeRPCError(w, nil, http.StatusMethodNotAllowed, ErrCodeInvalidRequest, "method not allowed")
return
}
defer r.Body.Close()
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeRPCError(w, nil, http.StatusOK, ErrCodeParseError, "parse error")
return
}
if req.JSONRPC != "2.0" {
writeRPCError(w, req.ID, http.StatusOK, ErrCodeInvalidRequest, "jsonrpc must be \"2.0\"")
return
}
switch req.Method {
case "message/send":
s.handleMessageSend(w, r, req)
case "tasks/get":
s.handleTasksGet(w, req)
case "tasks/cancel":
s.handleTasksCancel(w, r, req)
default:
writeRPCError(w, req.ID, http.StatusOK, ErrCodeMethodNotFound, "method not found: "+req.Method)
}
}
func (s *Server) handleMessageSend(w http.ResponseWriter, r *http.Request, rpcReq JSONRPCRequest) {
var params MessageSendParams
if err := json.Unmarshal(rpcReq.Params, &params); err != nil {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidParams, "invalid params")
return
}
prompt := extractPrompt(params.Message)
if strings.TrimSpace(prompt) == "" {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidParams, "message parts are empty")
return
}
// blocking defaults to true when configuration is absent or Blocking field is omitted.
blocking := params.Configuration == nil || params.Configuration.Blocking == nil || *params.Configuration.Blocking
handle, err := s.svc.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: s.cfg.NodeRef,
Adapter: s.resolveAdapter(),
Target: s.resolveTarget(),
SessionID: s.resolveSessionID(),
Prompt: prompt,
TimeoutSec: s.resolveTimeoutSec(),
Metadata: map[string]string{
"source": "a2a",
"blocking": fmt.Sprintf("%t", blocking),
},
})
if err != nil {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidRequest, err.Error())
return
}
taskID := s.tasks.create(handle.RunID, params.Message)
var snapshot *Task
if blocking {
snapshot = s.tasks.collectBlocking(taskID, handle)
} else {
s.tasks.collectBackground(taskID, handle)
// Get a snapshot after background drain is started; background goroutine
// owns the internal pointer from this point, so we never share it.
snapshot, _ = s.tasks.get(taskID)
}
writeRPCResult(w, rpcReq.ID, snapshot)
}
func (s *Server) handleTasksGet(w http.ResponseWriter, rpcReq JSONRPCRequest) {
var params TaskQueryParams
if err := json.Unmarshal(rpcReq.Params, &params); err != nil {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidParams, "invalid params")
return
}
task, ok := s.tasks.get(params.ID)
if !ok {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeTaskNotFound, "task not found: "+params.ID)
return
}
writeRPCResult(w, rpcReq.ID, task)
}
func (s *Server) handleTasksCancel(w http.ResponseWriter, r *http.Request, rpcReq JSONRPCRequest) {
var params TaskIDParams
if err := json.Unmarshal(rpcReq.Params, &params); err != nil {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidParams, "invalid params")
return
}
task, ok := s.tasks.get(params.ID)
if !ok {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeTaskNotFound, "task not found: "+params.ID)
return
}
if task.Status.State != StateWorking {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeNotCancelable, "task is not in working state")
return
}
_, err := s.svc.CancelRun(r.Context(), edgeservice.CancelRunRequest{
NodeRef: s.cfg.NodeRef,
RunID: params.ID,
Adapter: s.resolveAdapter(),
Target: s.resolveTarget(),
SessionID: s.resolveSessionID(),
})
if err != nil {
writeRPCError(w, rpcReq.ID, http.StatusOK, ErrCodeInvalidRequest, err.Error())
return
}
writeRPCResult(w, rpcReq.ID, map[string]string{"id": params.ID})
}
func (s *Server) resolveAdapter() string {
if s.cfg.Adapter != "" {
return s.cfg.Adapter
}
return "cli"
}
func (s *Server) resolveTarget() string {
return s.cfg.Target
}
func (s *Server) resolveSessionID() string {
if s.cfg.SessionID != "" {
return s.cfg.SessionID
}
return edgeservice.DefaultSessionID
}
func (s *Server) resolveTimeoutSec() int {
if s.cfg.TimeoutSec > 0 {
return s.cfg.TimeoutSec
}
return edgeservice.DefaultTimeoutSec
}
func extractPrompt(msg Message) string {
var b strings.Builder
for _, p := range msg.Parts {
if p.Type == "text" && p.Text != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Text)
}
}
return b.String()
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeRPCResult(w http.ResponseWriter, id any, result any) {
writeJSON(w, http.StatusOK, JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
ID: id,
})
}
func writeRPCError(w http.ResponseWriter, id any, httpStatus, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
_ = json.NewEncoder(w).Encode(JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCError{Code: code, Message: message},
ID: id,
})
}