Skip to content

Commit 2e70a6e

Browse files
authored
Merge pull request #5 from evolution-foundation/fix/EVO-2180-forward-inbound-media
feat(EVO-2180): forward incoming media to the AI Processor as A2A file parts
2 parents d9f8816 + bf97f9b commit 2e70a6e

13 files changed

Lines changed: 900 additions & 87 deletions

pkg/ai/model/a2a.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@ type A2ARequest struct {
99
ApiKey string // used for X-API-Key header (per-event auth)
1010
Message string // aggregated buffer content (FR-15)
1111
Metadata map[string]any // CRM metadata passed through to processor (tools context)
12+
Attachments []Attachment // EVO-2180: incoming media to forward as A2A file parts
13+
}
14+
15+
// Attachment is an incoming media item (image/audio/…) the adapter downloads and
16+
// forwards to the AI Processor as a base64 A2A file part.
17+
type Attachment struct {
18+
URL string // downloadable URL (Rails proxy on BACKEND_URL, reachable server-side)
19+
ContentType string // e.g. "image/jpeg"
20+
FileType string // CRM file_type: image/audio/video/file
1221
}
1322

1423
// jsonRPCRequest is the JSON-RPC 2.0 envelope sent to AI Processor.
1524
type JSONRPCRequest struct {
16-
JSONRPC string `json:"jsonrpc"`
17-
ID string `json:"id"`
18-
Method string `json:"method"`
19-
Params JSONRPCParams `json:"params"`
25+
JSONRPC string `json:"jsonrpc"`
26+
ID string `json:"id"`
27+
Method string `json:"method"`
28+
Params JSONRPCParams `json:"params"`
2029
}
2130

2231
type JSONRPCParams struct {
@@ -27,13 +36,22 @@ type JSONRPCParams struct {
2736
}
2837

2938
type JSONRPCMessage struct {
30-
Role string `json:"role"`
31-
Parts []JSONRPCPart `json:"parts"`
39+
Role string `json:"role"`
40+
Parts []JSONRPCPart `json:"parts"`
3241
}
3342

3443
type JSONRPCPart struct {
35-
Type string `json:"type"`
36-
Text string `json:"text,omitempty"`
44+
Type string `json:"type"`
45+
Text string `json:"text,omitempty"`
46+
File *JSONRPCFile `json:"file,omitempty"`
47+
}
48+
49+
// JSONRPCFile is a base64 file part. Field names/tags match what the AI Processor
50+
// reads (extract_files_from_message: name / mimeType / bytes).
51+
type JSONRPCFile struct {
52+
Name string `json:"name,omitempty"`
53+
MimeType string `json:"mimeType"`
54+
Bytes string `json:"bytes"` // base64-encoded content
3755
}
3856

3957
// A2AResponse is the JSON-RPC 2.0 response from AI Processor.

pkg/ai/service/ai_adapter.go

Lines changed: 224 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ package service
33
import (
44
"bytes"
55
"context"
6+
"encoding/base64"
67
"encoding/json"
78
"errors"
89
"fmt"
910
"io"
1011
"log/slog"
1112
"math/rand"
13+
"mime"
1214
"net/http"
15+
neturl "net/url"
16+
"path"
17+
"strings"
1318
"time"
1419

1520
brtErrors "github.com/EvolutionAPI/evo-bot-runtime/internal/errors"
@@ -19,6 +24,31 @@ import (
1924
// maxResponseBytes caps the AI Processor response body to prevent OOM on oversized payloads.
2025
const maxResponseBytes = 1 << 20 // 1 MiB
2126

27+
// maxAttachmentBytes caps a single downloaded incoming attachment (EVO-2180).
28+
// Images routinely exceed maxResponseBytes (1 MiB); base64 inflates ~33%, so keep
29+
// this conservative relative to the processor's request-body limit.
30+
const maxAttachmentBytes = 15 << 20 // 15 MiB
31+
32+
// maxAttachmentsTotalBytes caps the SUM of every attachment forwarded in one call.
33+
// The debounce window aggregates the media of all messages in it, so a per-file cap
34+
// alone lets a photo burst build a body of len(attachments) x maxAttachmentBytes.
35+
// Base64 inflates that ~33% and the gateway rejects the POST (nginx
36+
// client_max_body_size) — and a 413 is not retryable, so the customer would lose the
37+
// text reply as well. Over budget, the remaining attachments are dropped and the
38+
// call proceeds with what fits.
39+
const maxAttachmentsTotalBytes = 20 << 20 // 20 MiB (~27 MiB once base64-encoded)
40+
41+
// Attachment downloads run before the AI call and outside its retry ceiling, so they
42+
// need a bound of their own: reusing the AI timeout (AI_CALL_TIMEOUT_SECONDS,
43+
// default 30s) meant an unreachable media host stalled every turn for
44+
// timeout x len(attachments) before the processor was even called.
45+
// maxAttachmentDownload bounds one download; the whole set shares
46+
// attachmentsTotalTimeFactor times that.
47+
const (
48+
maxAttachmentDownload = 10 * time.Second
49+
attachmentsTotalTimeFactor = 3
50+
)
51+
2252
// maxBackoff caps the exponential backoff between retries so a large
2353
// AI_CALL_RETRY_BASE_MS or retry count cannot balloon the wait.
2454
const maxBackoff = 5 * time.Second
@@ -83,6 +113,12 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
83113
// fall back to the numeric ID only when the metadata is absent (legacy callers).
84114
userID := contactUserID(req.Metadata, req.ContactID)
85115

116+
// Message parts: text first, then one file part per downloaded attachment.
117+
// EVO-2180: downloads happen ONCE here (before Marshal), so the byte-identical
118+
// body is reused across retries.
119+
parts := []model.JSONRPCPart{{Type: "text", Text: req.Message}}
120+
parts = append(parts, a.buildFileParts(ctx, req)...)
121+
86122
rpcReq := model.JSONRPCRequest{
87123
JSONRPC: "2.0",
88124
ID: fmt.Sprintf("%d:%d", req.ContactID, req.ConversationID),
@@ -91,10 +127,8 @@ func (a *aiAdapter) Call(ctx context.Context, req *model.A2ARequest) (*model.Nor
91127
ContextID: contextID,
92128
UserID: userID,
93129
Message: model.JSONRPCMessage{
94-
Role: "user",
95-
Parts: []model.JSONRPCPart{
96-
{Type: "text", Text: req.Message},
97-
},
130+
Role: "user",
131+
Parts: parts,
98132
},
99133
Metadata: nonNilMetadata(req.Metadata),
100134
},
@@ -287,6 +321,192 @@ func nonNilMetadata(m map[string]any) map[string]any {
287321
return m
288322
}
289323

324+
// buildFileParts downloads each incoming attachment and returns it as a base64 A2A
325+
// file part. A failure (unreachable/private URL, non-200, oversize, timeout) is
326+
// logged and skipped — the text-only message must always survive a media failure.
327+
// EVO-2180.
328+
func (a *aiAdapter) buildFileParts(ctx context.Context, req *model.A2ARequest) []model.JSONRPCPart {
329+
if len(req.Attachments) == 0 {
330+
return nil
331+
}
332+
perDownload := a.attachmentTimeout()
333+
budgetCtx, cancelBudget := context.WithTimeout(ctx, attachmentsTotalTimeFactor*perDownload)
334+
defer cancelBudget()
335+
336+
parts := make([]model.JSONRPCPart, 0, len(req.Attachments))
337+
remaining := maxAttachmentsTotalBytes
338+
for i, att := range req.Attachments {
339+
if att.URL == "" {
340+
continue
341+
}
342+
if budgetCtx.Err() != nil {
343+
slog.Warn("pipeline.ai.attachment.budget_exhausted",
344+
"contact_id", req.ContactID,
345+
"conversation_id", req.ConversationID,
346+
"limit", "time",
347+
"forwarded", len(parts),
348+
"dropped", len(req.Attachments)-i,
349+
)
350+
break
351+
}
352+
limit := min(maxAttachmentBytes, remaining)
353+
if limit <= 0 {
354+
slog.Warn("pipeline.ai.attachment.budget_exhausted",
355+
"contact_id", req.ContactID,
356+
"conversation_id", req.ConversationID,
357+
"limit", "bytes",
358+
"forwarded", len(parts),
359+
"dropped", len(req.Attachments)-i,
360+
)
361+
break
362+
}
363+
data, respContentType, err := a.downloadAttachment(budgetCtx, att.URL, perDownload, limit)
364+
if err != nil {
365+
slog.Warn("pipeline.ai.attachment.download_failed",
366+
"contact_id", req.ContactID,
367+
"conversation_id", req.ConversationID,
368+
"file_type", att.FileType,
369+
"error", err,
370+
)
371+
continue
372+
}
373+
mimeType, ok := resolveMimeType(att, respContentType)
374+
if !ok {
375+
slog.Warn("pipeline.ai.attachment.skipped_not_media",
376+
"contact_id", req.ContactID,
377+
"conversation_id", req.ConversationID,
378+
"file_type", att.FileType,
379+
"declared_content_type", att.ContentType,
380+
"response_content_type", respContentType,
381+
)
382+
continue
383+
}
384+
remaining -= len(data)
385+
parts = append(parts, model.JSONRPCPart{
386+
Type: "file",
387+
File: &model.JSONRPCFile{
388+
Name: attachmentName(att.URL),
389+
MimeType: mimeType,
390+
Bytes: base64.StdEncoding.EncodeToString(data),
391+
},
392+
})
393+
slog.Info("pipeline.ai.attachment.forwarded",
394+
"contact_id", req.ContactID,
395+
"conversation_id", req.ConversationID,
396+
"file_type", att.FileType,
397+
"mime_type", mimeType,
398+
"bytes", len(data),
399+
)
400+
}
401+
return parts
402+
}
403+
404+
// attachmentTimeout is the per-download timeout: maxAttachmentDownload, shrunk to
405+
// the configured AI timeout when that is smaller (so a deployment tuned for fast
406+
// failure does not wait longer on media than on the AI call itself).
407+
func (a *aiAdapter) attachmentTimeout() time.Duration {
408+
d := maxAttachmentDownload
409+
if t := time.Duration(a.timeoutSecs) * time.Second; t > 0 && t < d {
410+
d = t
411+
}
412+
return d
413+
}
414+
415+
// downloadAttachment GETs the URL with the adapter's client and the given timeout,
416+
// reading at most limit bytes. It returns the body and the response Content-Type so
417+
// the caller can decide what the bytes actually are.
418+
func (a *aiAdapter) downloadAttachment(ctx context.Context, url string, timeout time.Duration, limit int) ([]byte, string, error) {
419+
dlCtx, cancel := context.WithTimeout(ctx, timeout)
420+
defer cancel()
421+
422+
httpReq, err := http.NewRequestWithContext(dlCtx, http.MethodGet, url, nil)
423+
if err != nil {
424+
return nil, "", fmt.Errorf("new_request: %w", err)
425+
}
426+
resp, err := a.client.Do(httpReq)
427+
if err != nil {
428+
return nil, "", fmt.Errorf("do: %w", err)
429+
}
430+
defer resp.Body.Close()
431+
if resp.StatusCode != http.StatusOK {
432+
return nil, "", fmt.Errorf("unexpected status %d", resp.StatusCode)
433+
}
434+
// +1 so an exactly-at-cap read is distinguishable from an oversize one.
435+
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
436+
if err != nil {
437+
return nil, "", fmt.Errorf("read: %w", err)
438+
}
439+
if len(data) > limit {
440+
return nil, "", fmt.Errorf("attachment exceeds the %d bytes still available in this call", limit)
441+
}
442+
return data, resp.Header.Get("Content-Type"), nil
443+
}
444+
445+
// resolveMimeType picks the mime type sent to the AI Processor, which forwards it
446+
// verbatim into the model call (runner_utils: Blob(mime_type=content_type)) — so a
447+
// wrong value here surfaces as a processor-side failure, not a graceful skip.
448+
//
449+
// It prefers the Content-Type of the response actually downloaded (that describes
450+
// the bytes in hand), falls back to what the CRM declared, then to the URL
451+
// extension. It returns false when the payload is a web page: a Rails proxy URL
452+
// answering 200 with an error/login page would otherwise be forwarded as a valid
453+
// image. It also returns false when nothing better than application/octet-stream
454+
// can be determined — an opaque blob is rejected by the model APIs, so dropping it
455+
// keeps the text reply alive instead of failing the whole turn.
456+
func resolveMimeType(att model.Attachment, respContentType string) (string, bool) {
457+
respType := mediaTypeOf(respContentType)
458+
declared := mediaTypeOf(att.ContentType)
459+
if isWebPage(respType) || isWebPage(declared) {
460+
return "", false
461+
}
462+
for _, candidate := range []string{respType, declared, mediaTypeOf(mimeFromURL(att.URL))} {
463+
if candidate != "" && candidate != octetStream {
464+
return candidate, true
465+
}
466+
}
467+
return "", false
468+
}
469+
470+
const octetStream = "application/octet-stream"
471+
472+
// mediaTypeOf normalises a Content-Type header to its bare media type
473+
// ("image/jpeg; charset=binary" → "image/jpeg").
474+
func mediaTypeOf(contentType string) string {
475+
if contentType == "" {
476+
return ""
477+
}
478+
if mt, _, err := mime.ParseMediaType(contentType); err == nil {
479+
return mt
480+
}
481+
return strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
482+
}
483+
484+
// isWebPage reports whether a media type is an HTML document rather than media.
485+
func isWebPage(mediaType string) bool {
486+
return mediaType == "text/html" || mediaType == "application/xhtml+xml"
487+
}
488+
489+
// mimeFromURL guesses a mime type from the URL's file extension. ActiveStorage
490+
// proxy URLs keep the original filename, so this recovers the type when neither the
491+
// CRM nor the storage backend declares a useful one.
492+
func mimeFromURL(rawURL string) string {
493+
u, err := neturl.Parse(rawURL)
494+
if err != nil {
495+
return ""
496+
}
497+
return mime.TypeByExtension(path.Ext(u.Path))
498+
}
499+
500+
// attachmentName derives a filename from the URL path (fallback "file").
501+
func attachmentName(rawURL string) string {
502+
if u, err := neturl.Parse(rawURL); err == nil {
503+
if base := path.Base(u.Path); base != "" && base != "." && base != "/" {
504+
return base
505+
}
506+
}
507+
return "file"
508+
}
509+
290510
// conversationContextID resolves the contextId for the JSON-RPC call. It reads the
291511
// conversation UUID the CRM nests at metadata.evoai_crm_data.conversation.id and
292512
// returns it; if any hop is missing or empty it falls back to the numeric

0 commit comments

Comments
 (0)