99 "io"
1010 "log/slog"
1111 "net/http"
12+ "strings"
1213 "time"
1314
1415 brtErrors "github.com/EvolutionAPI/evo-bot-runtime/internal/errors"
@@ -18,24 +19,23 @@ import (
1819// maxResponseBytes caps the AI Processor response body to prevent OOM on oversized payloads.
1920const maxResponseBytes = 1 << 20 // 1 MiB
2021
21- // AIAdapter calls the AI Processor via A2A protocol.
22+ // AIAdapter calls the AI Processor via A2A protocol (JSON-RPC 2.0) .
2223// Swap the backend by providing a different implementation at main.go wiring.
2324type AIAdapter interface {
2425 Call (ctx context.Context , req * model.A2ARequest ) (* model.NormalizedResponse , error )
2526}
2627
2728type aiAdapter struct {
28- url string
29- apiKey string
29+ baseURL string
3030 timeoutSecs int
3131 client * http.Client
3232}
3333
3434// NewAIAdapter constructs the adapter. Returns interface (GEAR R03).
35- func NewAIAdapter (url , apiKey string , timeoutSecs int ) AIAdapter {
35+ // baseURL is the AI Processor base URL without path (e.g. http://ai-processor:8000).
36+ func NewAIAdapter (baseURL string , timeoutSecs int ) AIAdapter {
3637 return & aiAdapter {
37- url : url ,
38- apiKey : apiKey ,
38+ baseURL : strings .TrimRight (baseURL , "/" ),
3939 timeoutSecs : timeoutSecs ,
4040 client : & http.Client {},
4141 }
@@ -45,24 +45,41 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
4545 start := time .Now ()
4646
4747 // Wrap with timeout — inner timeout, outer ctx for pipeline cancellation.
48- // Error discrimination order matters:
49- // 1. ctx.Err() first → pipeline cancellation (outer context, set by PipelineService.Cancel)
50- // 2. timeoutCtx.Err() → AI timeout (inner context, set by WithTimeout)
51- // 3. default → generic HTTP error
5248 timeoutCtx , cancel := context .WithTimeout (ctx , time .Duration (a .timeoutSecs )* time .Second )
5349 defer cancel ()
5450
55- body , err := json .Marshal (req )
51+ // Build per-event URL: {baseURL}/api/v1/a2a/{agent_bot_id}
52+ url := fmt .Sprintf ("%s/api/v1/a2a/%s" , a .baseURL , req .AgentBotID )
53+
54+ // Build JSON-RPC 2.0 envelope
55+ rpcReq := model.JSONRPCRequest {
56+ JSONRPC : "2.0" ,
57+ ID : fmt .Sprintf ("%d:%d" , req .ContactID , req .ConversationID ),
58+ Method : "message/send" ,
59+ Params : model.JSONRPCParams {
60+ ContextID : fmt .Sprintf ("%d" , req .ConversationID ),
61+ UserID : fmt .Sprintf ("%d" , req .ContactID ),
62+ Message : model.JSONRPCMessage {
63+ Role : "user" ,
64+ Parts : []model.JSONRPCPart {
65+ {Type : "text" , Text : req .Message },
66+ },
67+ },
68+ Metadata : map [string ]any {},
69+ },
70+ }
71+
72+ body , err := json .Marshal (rpcReq )
5673 if err != nil {
5774 return nil , fmt .Errorf ("pipeline.ai.marshal: %w" , err )
5875 }
5976
60- httpReq , err := http .NewRequestWithContext (timeoutCtx , http .MethodPost , a . url , bytes .NewReader (body ))
77+ httpReq , err := http .NewRequestWithContext (timeoutCtx , http .MethodPost , url , bytes .NewReader (body ))
6178 if err != nil {
6279 return nil , fmt .Errorf ("pipeline.ai.new_request: %w" , err )
6380 }
6481 httpReq .Header .Set ("Content-Type" , "application/json" )
65- httpReq .Header .Set ("Authorization " , "Bearer " + a . apiKey )
82+ httpReq .Header .Set ("X-API-Key " , req . ApiKey )
6683
6784 resp , err := a .client .Do (httpReq )
6885 if err != nil {
@@ -90,11 +107,40 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
90107 return nil , fmt .Errorf ("pipeline.ai.decode: %w" , err )
91108 }
92109
110+ content := extractResponseText (& a2aResp )
111+
93112 slog .Info ("pipeline.ai.http.completed" ,
94113 "contact_id" , req .ContactID ,
95114 "conversation_id" , req .ConversationID ,
96115 "duration_ms" , time .Since (start ).Milliseconds (),
97116 )
98117
99- return & model.NormalizedResponse {Content : a2aResp .Content }, nil
118+ return & model.NormalizedResponse {Content : content }, nil
119+ }
120+
121+ // extractResponseText extracts the text content from the A2A JSON-RPC response.
122+ // Tries result.artifacts[0].parts[0].text first, then result.message.parts[0].text.
123+ func extractResponseText (resp * model.A2AResponse ) string {
124+ if resp .Result == nil {
125+ return ""
126+ }
127+ // Try artifacts first (primary response format)
128+ if len (resp .Result .Artifacts ) > 0 {
129+ for _ , artifact := range resp .Result .Artifacts {
130+ for _ , part := range artifact .Parts {
131+ if part .Text != "" {
132+ return part .Text
133+ }
134+ }
135+ }
136+ }
137+ // Fallback to message format
138+ if resp .Result .Message != nil {
139+ for _ , part := range resp .Result .Message .Parts {
140+ if part .Text != "" {
141+ return part .Text
142+ }
143+ }
144+ }
145+ return ""
100146}
0 commit comments