원격 터미널 브리지 POC 전에 Client HTTP lifecycle과 Edge run result surface 계약을 고정해야 하므로 관련 구현, 테스트, 로드맵 상태를 함께 정리한다.
303 lines
8 KiB
Go
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/go/config"
|
|
)
|
|
|
|
type runService interface {
|
|
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, 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, ¶ms); 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.Dispatch().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, ¶ms); 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, ¶ms); 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,
|
|
})
|
|
}
|