Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
LISTEN_ADDR=:8090
REDIS_URL=redis://localhost:6379

# Required. Shared with the CRM, which sends it as X-Bot-Runtime-Secret on /events.
# An empty value would authenticate any caller that omits the header.
BOT_RUNTIME_SECRET=
AI_CALL_TIMEOUT_SECONDS=30

# CRM-236: a tool-calling turn makes two model calls and the provider's tail adds
# up to ~20s each. An explicit value here overrides the code default.
AI_CALL_TIMEOUT_SECONDS=90

# Message sent to the customer when the AI cannot answer (timeout or provider
# outage). Empty string disables it and restores the old silence.
# AI_FAILURE_NOTICE=We are having a temporary issue and could not answer right now. We will get back to you shortly.

# Required for incoming media. Hosts allowed to serve it, comma-separated, no
# scheme or port (e.g. "crm.example.com,minio.internal").
#
# Set it to the host of the CRM's BACKEND_URL — that is what signs the attachment
# URLs — plus the storage host when ActiveStorage runs in redirect mode, or the CDN
# host. Leave it empty and no media reaches the agent: an attachment on an unlisted
# host is skipped and logged as pipeline.ai.attachment.blocked_url, and only the
# text reply goes out.
MEDIA_HOST_ALLOWLIST=
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

# Nothing ran the Go suite on a PR before this: test/e2e sat non-compiling from
# EVO-558 to EVO-2180 without a single red check. Redis is a service container
# because the repository tests need a real one.

on:
pull_request:
push:
branches: [develop, main]

jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
REDIS_TEST_URL: redis://localhost:6379
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func main() {
debounce := debounceService.NewDebounceEngine(pipelineRepo)

// Step 6: AI adapter (URL comes from each event's outgoing_url)
aiAdapter := aiService.NewAIAdapter(cfg.AICallTimeoutSeconds)
aiAdapter := aiService.NewAIAdapter(cfg.AICallTimeoutSeconds, cfg.AICallMaxRetries, cfg.AICallRetryBaseMs)

// Step 7: dispatch engine (sends secret header on postback to CRM)
dispatch := dispatchService.NewDispatchEngine(cfg.BotRuntimeSecret)
Expand Down
26 changes: 24 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ type Config struct {
RedisURL string
BotRuntimeSecret string
AICallTimeoutSeconds int
// EVO-2167: retry the AI Processor call on transient failures (5xx/429/network)
// so a momentary blip (deploy, restart, DB hiccup) does not leave the customer
// without a reply. AICallMaxRetries is retries AFTER the first attempt.
AICallMaxRetries int
AICallRetryBaseMs int
}

func Load() (*Config, error) {
Expand All @@ -22,8 +27,23 @@ func Load() (*Config, error) {
if err != nil {
return nil, err
}
botRuntimeSecret := os.Getenv("BOT_RUNTIME_SECRET")
aiCallTimeout, err := getEnvIntOrDefault("AI_CALL_TIMEOUT_SECONDS", 30)
// Required: SecretMiddleware compares the header against this, so an empty value
// authenticates every caller that omits it.
botRuntimeSecret, err := mustGetEnv("BOT_RUNTIME_SECRET")
if err != nil {
return nil, err
}
// CRM-236: 90s, not 30. A tool-calling turn makes two model calls and the
// provider's tail alone measured 20.4s on a trivial prompt.
aiCallTimeout, err := getEnvIntOrDefault("AI_CALL_TIMEOUT_SECONDS", 90)
if err != nil {
return nil, err
}
aiCallMaxRetries, err := getEnvIntOrDefault("AI_CALL_MAX_RETRIES", 2)
if err != nil {
return nil, err
}
aiCallRetryBaseMs, err := getEnvIntOrDefault("AI_CALL_RETRY_BASE_MS", 200)
if err != nil {
return nil, err
}
Expand All @@ -33,6 +53,8 @@ func Load() (*Config, error) {
RedisURL: redisURL,
BotRuntimeSecret: botRuntimeSecret,
AICallTimeoutSeconds: aiCallTimeout,
AICallMaxRetries: aiCallMaxRetries,
AICallRetryBaseMs: aiCallRetryBaseMs,
}, nil
}

Expand Down
7 changes: 6 additions & 1 deletion k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ metadata:
data:
LISTEN_ADDR: ":8080"
AI_PROCESSOR_URL: "http://ai-processor:8000"
AI_CALL_TIMEOUT_SECONDS: "30"
# CRM-236: an explicit value overrides the code default, and this ConfigMap is
# what runs in staging/production.
AI_CALL_TIMEOUT_SECONDS: "90"
# Hosts allowed to serve incoming media, comma-separated (no scheme/port).
# Must include the host of the CRM's BACKEND_URL, or no media reaches the agent.
MEDIA_HOST_ALLOWLIST: ""
5 changes: 5 additions & 0 deletions k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ spec:
configMapKeyRef:
name: evo-bot-runtime-config
key: AI_CALL_TIMEOUT_SECONDS
- name: MEDIA_HOST_ALLOWLIST
valueFrom:
configMapKeyRef:
name: evo-bot-runtime-config
key: MEDIA_HOST_ALLOWLIST
# Secrets
- name: REDIS_URL
valueFrom:
Expand Down
34 changes: 26 additions & 8 deletions pkg/ai/model/a2a.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ type A2ARequest struct {
ApiKey string // used for X-API-Key header (per-event auth)
Message string // aggregated buffer content (FR-15)
Metadata map[string]any // CRM metadata passed through to processor (tools context)
Attachments []Attachment // EVO-2180: incoming media to forward as A2A file parts
}

// Attachment is an incoming media item (image/audio/…) the adapter downloads and
// forwards to the AI Processor as a base64 A2A file part.
type Attachment struct {
URL string // downloadable URL (Rails proxy on BACKEND_URL, reachable server-side)
ContentType string // e.g. "image/jpeg"
FileType string // CRM file_type: image/audio/video/file
}

// jsonRPCRequest is the JSON-RPC 2.0 envelope sent to AI Processor.
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params JSONRPCParams `json:"params"`
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params JSONRPCParams `json:"params"`
}

type JSONRPCParams struct {
Expand All @@ -27,13 +36,22 @@ type JSONRPCParams struct {
}

type JSONRPCMessage struct {
Role string `json:"role"`
Parts []JSONRPCPart `json:"parts"`
Role string `json:"role"`
Parts []JSONRPCPart `json:"parts"`
}

type JSONRPCPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
File *JSONRPCFile `json:"file,omitempty"`
}

// JSONRPCFile is a base64 file part. Field names/tags match what the AI Processor
// reads (extract_files_from_message: name / mimeType / bytes).
type JSONRPCFile struct {
Name string `json:"name,omitempty"`
MimeType string `json:"mimeType"`
Bytes string `json:"bytes"` // base64-encoded content
}

// A2AResponse is the JSON-RPC 2.0 response from AI Processor.
Expand Down
Loading
Loading