Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
const chat=document.getElementById('chat');
const form=document.getElementById('form');
let eventSource=null;
let currentSessionId = null;
function add(role,text){
const d=document.createElement('div');
d.className='msg '+role;
d.textContent=text;
chat.appendChild(d);
chat.scrollTop=chat.scrollHeight;
}
function status(text){const el=document.getElementById('status'); if(el) el.textContent=text;}
function val(id){return (document.getElementById(id)?.value || '').trim();}
function uuid(){return crypto.randomUUID();}
function buildBusinessContext(session, messageId){
return {
customer_key: val('customerKey') || null,
contract_key: val('contractKey') || null,
interaction_key: val('interactionKey') || messageId,
account_key: val('accountKey') || null,
resource_key: val('resourceKey') || null,
session_key: session || null,
metadata: {frontend: 'agent_frontend', version: 'business-context-v2'}
};
}
function syncDomainAliases(payload, businessContext){
const agent=val('agent');
if(agent === 'retail_orders'){
payload.customer_id = businessContext.customer_key;
payload.order_id = businessContext.contract_key;
} else {
payload.msisdn = businessContext.customer_key;
payload.invoice_id = businessContext.contract_key;
payload.ura_call_id = businessContext.interaction_key;
payload.asset_id = businessContext.resource_key;
}
}
function adicionarMensagem(role, text) {
const chat =
document.getElementById("chat") ||
document.getElementById("messages") ||
document.querySelector(".chat") ||
document.querySelector(".messages") ||
document.querySelector("[data-chat]");
if (!chat) {
console.error("Não encontrei o container do chat no HTML.");
console.log("Mensagem que seria exibida:", role, text);
return;
}
const div = document.createElement("div");
if (role === "user") {
div.className = "msg user chat-bubble--user";
} else {
div.className = "msg assistant chat-bubble--agent";
}
div.textContent = text || "";
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function abrirSSE(sessionId) {
if (!sessionId) {
console.error("Não vou abrir SSE sem sessionId.");
return;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
const url = `${backend}/gateway/events/${sessionId}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log("SSE OPEN");
};
eventSource.onerror = (err) => {
console.error("SSE ERROR:", err);
};
const eventos = [
"connected",
"waiting",
"backend.selected",
"flow.start",
"workflow.started",
"message.responded",
"workflow.completed",
"flow.end",
"error"
];
for (const nome of eventos) {
eventSource.addEventListener(nome, (event) => {
if (nome === "message.responded") {
try {
const data = JSON.parse(event.data);
const text =
data.text ||
data.message ||
data.response ||
data.content ||
data.output ||
event.data;
adicionarMensagem("assistant", text);
} catch {
adicionarMensagem("assistant", event.data);
}
}
if (nome === "error") {
adicionarMensagem("assistant", `Erro SSE: ${event.data}`);
}
});
}
}
function normalizeSessionId(value) {
if (!value) return uuid();
const parts = value.split(":");
return parts[parts.length - 1]; // mantém só o UUID final
}
function connectSSE(backend, sessionId) {
if (!sessionId) {
console.warn("SSE não aberto: sessionId ausente.");
return;
}
if (!backend) {
console.warn("SSE não aberto: backend ausente.");
return;
}
if (eventSource) {
console.log("Fechando SSE anterior:", eventSource.url);
eventSource.close();
eventSource = null;
}
const url = `${backend.replace(/\/$/, "")}/gateway/events/${encodeURIComponent(sessionId)}`;
console.log("Abrindo SSE:", url);
eventSource = new EventSource(url);
eventSource._sessionId = sessionId;
eventSource.onopen = () => {
console.log("SSE OPEN:", url);
status("SSE conectado");
};
eventSource.onerror = (err) => {
console.error("SSE ERROR raw:", err);
console.error("SSE readyState:", eventSource?.readyState);
console.error("SSE url:", eventSource?.url);
if (eventSource?.readyState === EventSource.CONNECTING) {
status("SSE aguardando/reconectando");
return;
}
if (eventSource?.readyState === EventSource.CLOSED) {
status("SSE fechado");
return;
}
status("SSE com erro");
};
eventSource.addEventListener("connected", (event) => {
console.log("SSE connected:", event.data);
status("SSE conectado");
});
eventSource.addEventListener("waiting", (event) => {
console.log("SSE waiting:", event.data);
status("SSE aguardando backend");
});
eventSource.addEventListener("backend.selected", (event) => {
console.log("SSE backend.selected:", event.data);
status("Backend selecionado");
});
eventSource.addEventListener("flow.start", (event) => {
console.log("SSE flow.start:", event.data);
status("Fluxo iniciado");
});
eventSource.addEventListener("workflow.started", (event) => {
console.log("SSE workflow.started:", event.data);
status("Workflow em execução");
});
eventSource.addEventListener("session.upserted", (event) => {
try {
const data = JSON.parse(event.data);
if (data.business_context) {
console.debug("business_context", data.business_context);
}
} catch (e) {
console.warn("Não consegui interpretar session.upserted:", event.data);
}
});
eventSource.addEventListener("message.responded", (event) => {
try {
const data = JSON.parse(event.data);
const text =
data.text ||
data.message ||
data.response ||
data.content ||
data.output ||
event.data;
if (text) {
add("assistant", text);
}
if (data.metadata?.business_context) {
console.debug("metadata.business_context", data.metadata.business_context);
}
status("Resposta recebida");
} catch (e) {
add("assistant", event.data);
status("Resposta recebida");
}
});
eventSource.addEventListener("workflow.completed", (event) => {
console.log("SSE workflow.completed:", event.data);
status("Workflow concluído");
});
eventSource.addEventListener("flow.end", (event) => {
console.log("SSE flow.end:", event.data);
status("Fluxo finalizado");
});
// Use um nome diferente de "error" para erro enviado pelo servidor.
// "error" é reservado/conflituoso com erro nativo do EventSource.
eventSource.addEventListener("server.error", (event) => {
console.error("SSE server.error:", event.data);
add("assistant", `Erro SSE: ${event.data || "erro informado pelo servidor"}`);
status("Erro no fluxo SSE");
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('message');
const text = input.value.trim();
if (!text) return;
adicionarMensagem('user', text);
input.value = '';
const backend = val('backend').replace(/\/$/, '');
const channel = val('channel');
// const session = val('session') || uuid();
const session = normalizeSessionId(val('session'));
const messageId = uuid();
const tenantId = val('tenant') || 'default';
const agentId = val('agent') || 'telecom_contas';
document.getElementById('session').value = session;
const businessContext = buildBusinessContext(session, messageId);
const commonContext = {
channel_id: 'browser',
tenant_id: tenantId,
agent_id: agentId,
business_context: businessContext
};
const payload = channel === 'voice'
? {
transcript: text,
session_id: session,
ani: businessContext.customer_key,
message_id: messageId,
tenant_id: tenantId,
agent_id: agentId,
context: commonContext
}
: {
message: text,
text: text,
session_id: session,
user_id: businessContext.customer_key || 'web-user',
message_id: messageId,
tenant_id: tenantId,
agent_id: agentId,
context: commonContext
};
syncDomainAliases(payload, businessContext);
try {
status('Enviando mensagem');
const res = await fetch(`${backend}/gateway/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel,
tenant_id: tenantId,
agent_id: agentId,
payload
})
});
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
const data = await res.json();
const returnedSessionId = data.session_id || session;
currentSessionId = returnedSessionId;
document.getElementById('session').value = returnedSessionId;
const resposta =
data.text ||
data.speak ||
data.message ||
data.response ||
data.content ||
data.output ||
JSON.stringify(data);
adicionarMensagem('assistant', resposta);
status('Resposta recebida');
} catch (err) {
adicionarMensagem('assistant', `Erro ao chamar backend: ${err.message}`);
status('Erro de conexão');
}
});

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI Agent Frontend</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="app">
<header>
<h1>AI Agent Platform</h1>
<p>Frontend independente com chaves de conversa propagadas até o framework e MCP Server.</p>
</header>
<section class="config">
<label>Backend URL <input id="backend" value="http://localhost:8000" /></label>
<label>Canal
<select id="channel"><option value="web">web</option><option value="whatsapp">whatsapp</option><option value="voice">voice</option></select>
</label>
<label>Tenant <input id="tenant" value="default" /></label>
<label>Agent
<select id="agent">
<option value="telecom_contas">telecom_contas</option>
<option value="retail_orders">retail_orders</option>
</select>
</label>
<label>Session ID <input id="session" placeholder="gerado automaticamente" /></label>
<label><input id="useSse" type="checkbox" checked /> Usar SSE</label>
<span id="status">SSE aguardando</span>
</section>
<section class="config identity">
<label>customer_key <input id="customerKey" value="11999999999" /></label>
<label>contract_key <input id="contractKey" value="3000131180" /></label>
<label>interaction_key <input id="interactionKey" placeholder="URA/call/message id" /></label>
<label>resource_key <input id="resourceKey" placeholder="asset/product/resource" /></label>
<label>account_key <input id="accountKey" placeholder="billing account/customer account" /></label>
</section>
<section id="chat" class="chat"></section>
<form id="form">
<input id="message" placeholder="Digite sua mensagem..." autocomplete="off" />
<button>Enviar</button>
</form>
</main>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
body{font-family:system-ui,Arial,sans-serif;background:#f6f7fb;margin:0}.app{max-width:900px;margin:0 auto;padding:24px}header{margin-bottom:16px}.config{display:grid;grid-template-columns:1fr 160px 1fr;gap:12px;margin-bottom:16px}.config input,.config select,#message{width:100%;padding:10px;border:1px solid #ccc;border-radius:8px}.chat{background:#fff;border:1px solid #ddd;border-radius:12px;min-height:420px;padding:16px;overflow:auto}.msg{padding:10px 12px;border-radius:10px;margin:8px 0;max-width:75%}.user{background:#e8f0ff;margin-left:auto}.assistant{background:#f0f0f0}form{display:flex;gap:8px;margin-top:12px}button{padding:10px 16px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}

View File

@@ -0,0 +1,35 @@
APP_NAME=agent-gateway-global-supervisor
APP_ENV=local
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=8010
CORS_ORIGINS=http://localhost:5173
BACKENDS_CONFIG_PATH=./config/backends.yaml
GLOBAL_ROUTING_MODE=hybrid
GLOBAL_KEEP_ACTIVE_BACKEND=true
GLOBAL_USE_SUPERVISOR_ON_CONFLICT=true
GLOBAL_MIN_ROUTER_CONFIDENCE=0.55
GLOBAL_SESSION_TTL_SECONDS=3600
BACKEND_TIMEOUT_SECONDS=120
# Para o supervisor global. Em dev, use mock; em produção, use oci_openai/openai_compatible.
LLM_PROVIDER=mock
LLM_TEMPERATURE=0
LLM_MAX_TOKENS=700
LLM_TIMEOUT_SECONDS=60
OCI_GENAI_BASE_URL=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1
OCI_GENAI_MODEL=openai.gpt-4.1
OCI_GENAI_API_KEY=
# Analytics do próprio gateway
ENABLE_ANALYTICS=false
ANALYTICS_PROVIDERS=oci_streaming,pubsub
GCP_PUBSUB_TOPIC_PATH=
AGENT_PUBSUB_TOPIC=
GCP_PROJECT_ID=
GCP_PUBSUB_TOPIC=
ENABLE_OCI_STREAMING=false
OCI_STREAM_ENDPOINT=
OCI_STREAM_OCID=
OCI_STREAM_PARTITION_KEY=agent-gateway-events

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY agent_framework /agent_framework
COPY agent_gateway /app
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -0,0 +1,73 @@
# Agent Gateway — Global Supervisor
Este serviço roteia uma mesma conversa entre vários backends de agentes independentes, todos usando o `agent_framework`.
## Papel do Gateway
```text
Frontend
Agent Gateway / Global Supervisor
Backend Contas | Backend Ofertas | Backend Suporte | ...
```
O Gateway não executa a lógica de negócio dos agentes. Ele decide **qual backend** deve receber a mensagem e encaminha a requisição para o endpoint `/gateway/message` do backend escolhido.
## Modos de roteamento
- `router`: usa regras, keywords e domínios do `config/backends.yaml`.
- `supervisor`: usa LLM para escolher o backend.
- `hybrid`: mantém o backend ativo quando a mensagem parece continuação; usa regras; chama LLM em ambiguidade.
## Como subir localmente
```bash
cd agent_gateway
cp .env.example .env
export PYTHONPATH=../agent_framework/src:.
uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload
```
Suba seus backends nas portas definidas em `config/backends.yaml`.
## Teste de rota
```bash
curl -X POST http://localhost:8010/debug/route \
-H 'content-type: application/json' \
-d '{"channel":"web","payload":{"text":"Minha fatura veio alta","session_id":"s1"}}'
```
## Enviar mensagem
```bash
curl -X POST http://localhost:8010/gateway/message \
-H 'content-type: application/json' \
-d '{"channel":"web","payload":{"text":"Minha fatura veio alta","session_id":"s1"}}'
```
## Handoff entre backends
Um backend pode solicitar troca retornando no `metadata`:
```json
{
"metadata": {
"handover_backend": "ofertas"
}
}
```
O Gateway chamará automaticamente o novo backend.
## IC/NOC
O Gateway emite eventos de observabilidade:
- `IC.GLOBAL_GATEWAY_RECEIVED`
- `IC.GLOBAL_BACKEND_SELECTED`
- `IC.GLOBAL_BACKEND_HANDOVER`
- `IC.GLOBAL_GATEWAY_COMPLETED`
- `NOC.005` em falhas
- `NOC.006` em conclusão HTTP

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
def load_gateway_governance_config(path: str | None = None) -> dict[str, Any]:
config_path = Path(path or os.getenv("AGENT_GATEWAY_GOVERNANCE_CONFIG", "config/gateway_governance.yaml"))
if not config_path.exists():
return {}
return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("agent_gateway.governance")
def audit_event(name: str, payload: dict[str, Any]) -> None:
safe = dict(payload)
if "message" in safe:
safe["message_len"] = len(str(safe.pop("message") or ""))
logger.info("%s %s", name, json.dumps(safe, ensure_ascii=False, default=str))

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
class EvaluationHooks:
def before_backend_call(self, request_payload: dict[str, Any]) -> dict[str, Any]:
return request_payload
def after_backend_call(self, response_payload: dict[str, Any]) -> dict[str, Any]:
return response_payload

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
class ModelPolicyError(RuntimeError):
pass
class ModelPolicyResolver:
def __init__(self, config: dict[str, Any]):
self.config = config or {}
def resolve_profile(
self,
*,
tenant_id: str,
agent_id: str | None,
operation: str,
requested_profile: str | None = None,
) -> dict[str, Any]:
profiles = self.config.get("profiles", {}) or {}
operation_profiles = self.config.get("operation_profiles", {}) or {}
profile_name = requested_profile or operation_profiles.get(operation) or "default"
profile = profiles.get(profile_name)
if not profile:
raise ModelPolicyError(f"Model profile not found: {profile_name}")
self._validate_policy(
tenant_id=tenant_id,
agent_id=agent_id,
profile_name=profile_name,
profile=profile,
)
return {
"profile": profile_name,
"provider": profile.get("provider"),
"model": profile.get("model"),
"parameters": {
k: v for k, v in profile.items()
if k not in {"provider", "model"}
},
}
def _validate_policy(
self,
*,
tenant_id: str,
agent_id: str | None,
profile_name: str,
profile: dict[str, Any],
) -> None:
policies = self.config.get("policies", {}) or {}
tenant_policies = policies.get("tenants", {}) or {}
tenant_policy = tenant_policies.get(tenant_id) or tenant_policies.get("default") or {}
allowed_profiles = tenant_policy.get("allowed_profiles")
if allowed_profiles and profile_name not in allowed_profiles:
raise ModelPolicyError(f"Profile not allowed for tenant={tenant_id}: {profile_name}")
allowed_providers = tenant_policy.get("allowed_providers")
provider = profile.get("provider")
if allowed_providers and provider not in allowed_providers:
raise ModelPolicyError(f"Provider not allowed for tenant={tenant_id}: {provider}")

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from typing import Any
class RateLimitExceeded(RuntimeError):
pass
class InMemoryRateLimiter:
def __init__(self, config: dict[str, Any]):
self.config = config or {}
self.events: dict[str, deque[float]] = defaultdict(deque)
def check(self, *, tenant_id: str, agent_id: str | None, channel: str | None) -> None:
default_limit = ((self.config.get("default") or {}).get("requests_per_minute")) or 600
agent_limits = self.config.get("agents") or {}
channel_limits = self.config.get("channels") or {}
limit = default_limit
if agent_id and agent_id in agent_limits:
limit = agent_limits[agent_id].get("requests_per_minute", limit)
if channel and channel in channel_limits:
limit = min(limit, channel_limits[channel].get("requests_per_minute", limit))
key = f"{tenant_id}:{agent_id or '*'}:{channel or '*'}"
now = time.time()
bucket = self.events[key]
while bucket and bucket[0] < now - 60:
bucket.popleft()
if len(bucket) >= int(limit):
raise RateLimitExceeded(f"Gateway rate limit exceeded for {key}: {limit}/min")
bucket.append(now)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Any
class UsageRecorder:
def record_gateway_request(self, payload: dict[str, Any]) -> None:
return None
def record_model_policy(self, payload: dict[str, Any]) -> None:
return None
def record_backend_response(self, payload: dict[str, Any]) -> None:
return None

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from app.config.governance_loader import load_gateway_governance_config
from app.governance.audit import audit_event
from app.governance.evaluation_hooks import EvaluationHooks
from app.governance.model_policies import ModelPolicyError, ModelPolicyResolver
from app.governance.rate_limit import InMemoryRateLimiter, RateLimitExceeded
from app.governance.usage import UsageRecorder
class AgentGatewayGovernance:
def __init__(self, config: dict[str, Any] | None = None):
self.config = config if config is not None else load_gateway_governance_config()
self.model_resolver = ModelPolicyResolver((self.config.get("model_governance") or {}))
self.rate_limiter = InMemoryRateLimiter((self.config.get("rate_limits") or {}))
self.usage = UsageRecorder()
self.eval_hooks = EvaluationHooks()
def prepare_backend_request(self, gateway_request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]:
tenant_id = gateway_request.get("tenant_id") or "default"
agent_id = gateway_request.get("agent_id")
channel = gateway_request.get("channel")
payload = gateway_request.get("payload") or {}
metadata = payload.setdefault("metadata", {})
try:
self.rate_limiter.check(tenant_id=tenant_id, agent_id=agent_id, channel=channel)
model_policy = self.model_resolver.resolve_profile(
tenant_id=tenant_id,
agent_id=agent_id,
operation=metadata.get("operation") or "agent.final_answer",
requested_profile=metadata.get("llm_profile"),
)
metadata["model_policy"] = model_policy
headers = {
"X-Agent-Gateway-Governance": "enabled",
"X-Model-Profile": str(model_policy.get("profile") or ""),
"X-Model-Provider": str(model_policy.get("provider") or ""),
"X-Model-Name": str(model_policy.get("model") or ""),
}
audit_event("agent_gateway.request.governed", {
"tenant_id": tenant_id,
"agent_id": agent_id,
"channel": channel,
"model_policy": model_policy,
"request_id": metadata.get("request_id"),
"message": payload.get("message"),
})
self.usage.record_gateway_request({
"tenant_id": tenant_id,
"agent_id": agent_id,
"channel": channel,
"metadata": metadata,
})
governed = self.eval_hooks.before_backend_call(gateway_request)
return governed, headers
except RateLimitExceeded as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
except ModelPolicyError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
def process_backend_response(self, response_payload: dict[str, Any]) -> dict[str, Any]:
response_payload = self.eval_hooks.after_backend_call(response_payload)
self.usage.record_backend_response(response_payload)
audit_event("agent_gateway.response.completed", {
"metadata": response_payload.get("metadata") if isinstance(response_payload, dict) else {},
})
return response_payload

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
import logging
import time
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.global_supervisor import (
BackendClient,
BackendRegistry,
GlobalRouteRequest,
GlobalSupervisorRouter,
InMemoryGlobalSessionStore,
)
from agent_framework.llm.providers import create_llm
from agent_framework.observability.observer import AgentObserver
from agent_framework.security import install_authentication
from app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_gateway")
app = FastAPI(title="Agent Gateway - Global Supervisor")
install_authentication(app, prefix="AGENT_GATEWAY_AUTH")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
registry = BackendRegistry.from_yaml(settings.BACKENDS_CONFIG_PATH)
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
llm = create_llm(settings)
session_store = InMemoryGlobalSessionStore(ttl_seconds=settings.GLOBAL_SESSION_TTL_SECONDS)
router = GlobalSupervisorRouter(
registry=registry,
llm=llm if settings.GLOBAL_ROUTING_MODE in {"supervisor", "hybrid"} else None,
session_store=session_store,
mode=settings.GLOBAL_ROUTING_MODE,
keep_active_backend=settings.GLOBAL_KEEP_ACTIVE_BACKEND,
use_supervisor_on_conflict=settings.GLOBAL_USE_SUPERVISOR_ON_CONFLICT,
min_router_confidence=settings.GLOBAL_MIN_ROUTER_CONFIDENCE,
)
backend_client = BackendClient(timeout_seconds=settings.BACKEND_TIMEOUT_SECONDS)
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict = Field(default_factory=dict)
tenant_id: str | None = None
agent_id: str | None = None
backend_id: str | None = None
session_id: str | None = None
metadata: dict = Field(default_factory=dict)
def _session_id(req: GatewayRequest) -> str:
return (
req.session_id
or req.payload.get("session_id")
or req.payload.get("conversation_key")
or req.payload.get("original_session_id")
or str(uuid4())
)
def _as_backend_request(req: GatewayRequest, session_id: str) -> dict:
# Mantém o contrato do agent_template_backend: {channel, payload, agent_id, tenant_id}
payload = dict(req.payload or {})
payload.setdefault("session_id", session_id)
return {
"channel": req.channel,
"payload": payload,
"agent_id": req.agent_id,
"tenant_id": req.tenant_id or payload.get("tenant_id") or "default",
}
@app.middleware("http")
async def noc_middleware(request: Request, call_next):
started = time.time()
try:
response = await call_next(request)
await observer.emit_noc("006", {"component": "agent_gateway", "path": request.url.path, "status_code": response.status_code, "duration_ms": int((time.time() - started) * 1000)})
return response
except Exception as exc:
await observer.emit_noc("005", {"component": "agent_gateway", "path": request.url.path, "error": str(exc), "duration_ms": int((time.time() - started) * 1000)})
raise
@app.get("/health")
async def health():
return {
"status": "ok",
"app": settings.APP_NAME,
"routing_mode": settings.GLOBAL_ROUTING_MODE,
"backends": [b.backend_id for b in registry.list()],
"llm_provider": settings.LLM_PROVIDER,
}
@app.get("/backends")
async def backends():
return registry.as_dict()
@app.get("/backends/health")
async def backends_health():
results = []
for backend in registry.list():
results.append(await backend_client.health(backend))
return {"results": results}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
session_id = _session_id(req)
route_req = GlobalRouteRequest(
channel=req.channel,
payload=req.payload,
tenant_id=req.tenant_id,
session_id=session_id,
force_backend=req.backend_id,
metadata=req.metadata,
)
decision = await router.route(route_req)
return decision.model_dump(mode="json")
@app.get("/debug/sessions")
async def debug_sessions():
return await session_store.dump()
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
started = time.time()
session_id = _session_id(req)
tenant_id = req.tenant_id or req.payload.get("tenant_id") or "default"
await observer.emit_ic("GLOBAL_GATEWAY_RECEIVED", {"session_id": session_id, "tenant_id": tenant_id, "channel": req.channel})
route_req = GlobalRouteRequest(
channel=req.channel,
payload=req.payload,
tenant_id=tenant_id,
session_id=session_id,
force_backend=req.backend_id,
metadata=req.metadata,
)
decision = await router.route(route_req)
backend = registry.get(decision.backend_id)
await observer.emit_ic("GLOBAL_BACKEND_SELECTED", {"session_id": session_id, "backend_id": backend.backend_id, "confidence": decision.confidence, "reason": decision.reason})
try:
result = await backend_client.call_message(backend, _as_backend_request(req, session_id), decision)
except Exception as exc:
await observer.emit_noc("005", {"component": "agent_gateway", "backend_id": backend.backend_id, "session_id": session_id, "error": str(exc)})
raise HTTPException(status_code=502, detail={"message": "Falha ao chamar backend selecionado", "backend_id": backend.backend_id, "error": str(exc)})
# Handoff opcional: backend pode pedir troca via metadata.handover_backend.
response = result.response
backend_session_id = (
response.get("session_id")
or response.get("metadata", {}).get("conversation_key")
)
if backend_session_id:
session_data = await session_store.set_active_backend(
session_id=session_id,
backend_id=backend.backend_id,
tenant_id=tenant_id,
backend_session_id=backend_session_id,
)
response["session_id"] = session_id
metadata = response.get("metadata") or {}
metadata["backend_session_id"] = backend_session_id
metadata["global_session_id"] = session_id
response["metadata"] = metadata
response["session_id"] = session_id
metadata = response.get("metadata") or {}
handover_backend = metadata.get("handover_backend") or metadata.get("handover_to_backend")
if handover_backend and handover_backend in registry.backends and handover_backend != backend.backend_id:
await observer.emit_ic("GLOBAL_BACKEND_HANDOVER", {"session_id": session_id, "from_backend": backend.backend_id, "to_backend": handover_backend})
forced = GatewayRequest(**req.model_dump())
forced.backend_id = handover_backend
forced.payload = {**forced.payload, "handover_from_backend": backend.backend_id}
return await gateway_message(forced)
await observer.emit_ic("GLOBAL_GATEWAY_COMPLETED", {"session_id": session_id, "backend_id": backend.backend_id, "elapsed_ms": int((time.time() - started) * 1000)})
metadata = dict(metadata)
metadata["global_route_decision"] = decision.model_dump(mode="json")
metadata["selected_backend"] = backend.backend_id
metadata["backend_elapsed_ms"] = result.elapsed_ms
response["metadata"] = metadata
return response
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
# Para simplificar o contrato, primeiro roteia via gateway e delega ao endpoint SSE do backend.
# O frontend pode continuar usando /gateway/events/{session_id} diretamente no backend escolhido,
# ou evoluir para um proxy SSE no gateway.
return await gateway_message(req)
from fastapi.responses import StreamingResponse
import httpx
import asyncio
@app.get("/gateway/events/{session_id:path}")
async def gateway_events(session_id: str):
async def stream():
yield (
"event: connected\n"
f'data: {{"session_id":"{session_id}","component":"agent_gateway"}}\n\n'
)
session_data = await session_store.get(session_id)
while not session_data:
yield (
"event: waiting\n"
f'data: {{"session_id":"{session_id}"}}\n\n'
)
await asyncio.sleep(1)
session_data = await session_store.get(session_id)
logger.error("SESSION_DATA SSE = %s", session_data)
backend_id = session_data.active_backend
backend_session_id = session_id
if not backend_id:
yield (
"event: error\n"
f'data: {{"message":"Sessão encontrada sem active_backend",'
f'"session_id":"{session_id}"}}\n\n'
)
return
backend = registry.get(backend_id)
backend_base_url = (
getattr(backend, "base_url", None)
or getattr(backend, "url", None)
or getattr(backend, "endpoint", None)
or getattr(backend, "base_endpoint", None)
)
if not backend_base_url:
yield (
"event: error\n"
f'data: {{"message":"Backend sem URL configurada",'
f'"backend_id":"{backend_id}"}}\n\n'
)
return
backend_sse_url = (
f"{backend_base_url.rstrip('/')}/gateway/events/{backend_session_id}"
)
yield (
"event: backend.selected\n"
f'data: {{"session_id":"{session_id}",'
f'"backend_id":"{backend_id}",'
f'"backend_session_id":"{backend_session_id}",'
f'"backend_sse_url":"{backend_sse_url}"}}\n\n'
)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", backend_sse_url) as response:
content_type = response.headers.get("content-type", "")
if response.status_code != 200:
body = await response.aread()
yield (
"event: error\n"
f'data: {{"message":"Backend SSE retornou erro",'
f'"status_code":{response.status_code},'
f'"content_type":"{content_type}",'
f'"body":{body.decode("utf-8", errors="replace")!r}}}\n\n'
)
return
if "text/event-stream" not in content_type:
body = await response.aread()
yield (
"event: error\n"
f'data: {{"message":"Backend SSE não retornou text/event-stream",'
f'"status_code":{response.status_code},'
f'"content_type":"{content_type}",'
f'"body":{body.decode("utf-8", errors="replace")!r}}}\n\n'
)
return
async for chunk in response.aiter_text():
if chunk:
yield chunk
except Exception as exc:
logger.exception("Erro no proxy SSE do gateway")
yield (
"event: error\n"
f'data: {{"message":"Erro no proxy SSE do gateway",'
f'"error":"{str(exc)}"}}\n\n'
)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from app.governance_middleware import AgentGatewayGovernance
router = APIRouter()
governance = AgentGatewayGovernance()
@router.post("/gateway/message/governed")
async def governed_gateway_message(request: Request):
"""Example governed proxy route.
Use as reference to patch the existing /gateway/message handler.
"""
body: dict[str, Any] = await request.json()
backend_url = os.getenv("DEFAULT_AGENT_BACKEND_URL", "http://localhost:8000")
governed_body, headers = governance.prepare_backend_request(body)
try:
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(
f"{backend_url.rstrip('/')}/gateway/message",
json=governed_body,
headers=headers,
)
resp.raise_for_status()
data = resp.json()
return governance.process_backend_response(data)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=exc.response.status_code, detail=exc.response.text) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class GatewaySettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
APP_NAME: str = "agent-gateway-global-supervisor"
APP_ENV: str = "local"
LOG_LEVEL: str = "INFO"
API_HOST: str = "0.0.0.0"
API_PORT: int = 8010
CORS_ORIGINS: str = "http://localhost:5173"
BACKENDS_CONFIG_PATH: str = "./config/backends.yaml"
GLOBAL_ROUTING_MODE: Literal["router", "supervisor", "hybrid"] = "hybrid"
GLOBAL_KEEP_ACTIVE_BACKEND: bool = True
GLOBAL_USE_SUPERVISOR_ON_CONFLICT: bool = True
GLOBAL_MIN_ROUTER_CONFIDENCE: float = 0.55
GLOBAL_SESSION_TTL_SECONDS: int = 3600
BACKEND_TIMEOUT_SECONDS: float = 120.0
# Reusa o provider do framework para o supervisor LLM.
LLM_PROVIDER: Literal["mock", "oci_openai", "oci_sdk", "openai_compatible"] = "mock"
LLM_TEMPERATURE: float = 0.0
LLM_MAX_TOKENS: int = 700
LLM_TIMEOUT_SECONDS: int = 60
OCI_GENAI_BASE_URL: str = "https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1"
OCI_GENAI_MODEL: str = "openai.gpt-4.1"
OCI_GENAI_API_KEY: str | None = None
ENABLE_LANGFUSE: bool = False
LANGFUSE_PUBLIC_KEY: str | None = None
LANGFUSE_SECRET_KEY: str | None = None
LANGFUSE_HOST: str = "https://cloud.langfuse.com"
MODEL_PRICES_JSON: str | None = None
USD_BRL_RATE: str = "5.0"
# Analytics/Observer do próprio gateway.
ENABLE_ANALYTICS: bool = False
ANALYTICS_PROVIDERS: str = "oci_streaming"
GCP_PUBSUB_TOPIC_PATH: str | None = None
AGENT_PUBSUB_TOPIC: str | None = None
GCP_PROJECT_ID: str | None = None
GCP_PUBSUB_TOPIC: str | None = None
GCP_PUBSUB_TIMEOUT_SECONDS: float = 30.0
ANALYTICS_FAIL_SILENT: bool = True
ENABLE_OCI_STREAMING: bool = False
OCI_STREAM_ENDPOINT: str | None = None
OCI_STREAM_OCID: str | None = None
OCI_STREAM_PARTITION_KEY: str = "agent-gateway-events"
@lru_cache
def get_settings() -> GatewaySettings:
return GatewaySettings()
settings = get_settings()

View File

@@ -0,0 +1,39 @@
# Nunca coloque secrets diretamente neste arquivo. Use sempre *_env.
providers:
public:
mode: none
deny:
mode: deny
tia_basic:
mode: basic
client_id_env: TIA_AGENT_CLIENT_ID
secret_hash_env: TIA_AGENT_SECRET_HASH
realm: agent-contas
platform_jwt:
mode: jwt
key_env: PLATFORM_JWT_PUBLIC_KEY
algorithms: [RS256]
audience: agent-platform
issuer: https://identity.example.com/
policies:
- name: health-public
provider: public
paths: [/health, /ready, /live]
- name: tia-agent-api
provider: tia_basic
paths: [/gateway/message, /gateway/message/sse, /gateway/events/*]
methods: [GET, POST]
- name: admin-api
provider: platform_jwt
paths: [/debug/*, /admin/*]
required_roles: [platform-admin]
required_scopes: [agent.admin]
# Quando nenhuma política casar, rejeita. O default omitido também é deny.
default_provider: deny

View File

@@ -0,0 +1,38 @@
default_backend: contas
backends:
contas:
url: http://localhost:8000
description: Backend responsável por faturas, contas, pagamentos, consumo, segunda via e contestação.
domains: [contas, fatura, pagamento, consumo, contestacao]
keywords: [fatura, conta, boleto, pagamento, consumo, segunda via, contestar, contestação, valor, cobrança]
examples:
- Quero consultar minha fatura
- Minha conta veio alta
- Preciso da segunda via do boleto
priority: 10
default_agent_id: telecom_contas
ofertas:
url: http://localhost:8001
description: Backend responsável por ofertas, planos, upgrades, retenção e contratação.
domains: [ofertas, planos, retenção, contratação]
keywords: [oferta, plano, contratar, upgrade, desconto, promoção, pacote, retenção, cancelar serviço]
examples:
- Quero trocar meu plano
- Tem alguma oferta para mim?
- Quero cancelar um serviço
priority: 20
default_agent_id: telecom_ofertas
suporte:
url: http://localhost:8002
description: Backend responsável por suporte técnico, falhas, rede, internet e atendimento operacional.
domains: [suporte, técnico, rede, internet]
keywords: [internet, sinal, rede, suporte, técnico, problema, falha, sem conexão, modem]
examples:
- Minha internet está lenta
- Estou sem sinal
- Preciso de suporte técnico
priority: 30
default_agent_id: telecom_suporte

View File

@@ -0,0 +1,56 @@
model_governance:
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
operation_profiles:
router.intent: router
agent.final_answer: default
judge.response_quality: judge
policies:
tenants:
default:
allowed_providers:
- oci_openai
- oci_sdk
- mock
allowed_profiles:
- default
- router
- judge
rate_limits:
default:
requests_per_minute: 600
channels:
whatsapp:
requests_per_minute: 120
web:
requests_per_minute: 300
agents:
telecom_contas:
requests_per_minute: 180
backend_headers:
propagate_model_policy: true
propagate_trace_context: true
evaluation:
enabled: true
sample_rate: 1.0

View File

@@ -0,0 +1,31 @@
# Arquitetura — Global Supervisor
```text
Usuário / Frontend
┌───────────────────────────────┐
│ Agent Gateway │
│ Global Supervisor │
│ │
│ - Router por regras │
│ - Supervisor via LLM │
│ - Híbrido stateful │
│ - Handoff entre backends │
└───────────────┬───────────────┘
┌─────────┼─────────┬────────────┐
▼ ▼ ▼ ▼
Backend Backend Backend Backend
Contas Ofertas Suporte Cobrança
```
Cada backend continua sendo um projeto independente, com seus próprios agentes, prompts, MCPs e deploy, mas todos usam a mesma biblioteca `agent_framework`.
## Estado global
O Gateway mantém um `active_backend` por `session_id`. No modo `hybrid`, mensagens curtas como "e esse valor?" continuam no backend ativo sem chamar LLM.
## Memória compartilhada
Para produção, configure os backends para usar o mesmo Session/Memory/Checkpoint Repository, preferencialmente Autonomous DB, Oracle, MongoDB ou Redis + DB.

View File

@@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
PyYAML>=6.0.2
httpx>=0.27.0
python-dotenv>=1.0.1

View File

@@ -0,0 +1,17 @@
APP_NAME=external-channel-gateway
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=7000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# adapter = receive channel payloads at /channels/* and convert to GatewayRequest.
# proxy = receive only GatewayRequest at /gateway/message and forward it.
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
AGENT_FRAMEWORK_GATEWAY_PATH=/gateway/message
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
REQUEST_TIMEOUT_SECONDS=120
# Optional: shared token added to calls from channel_gateway to backend.
INTERNAL_GATEWAY_TOKEN=

View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config ./config
ENV PYTHONPATH=/app
EXPOSE 7000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000"]

View File

@@ -0,0 +1,112 @@
# External Channel Gateway
This service is a separate Channel Gateway that sits in front of the Agent Framework backend.
It has its own runtime mode, independent from the backend input mode.
## Runtime modes
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
`adapter` means this service receives channel-specific payloads and translates them into `GatewayRequest` before calling the Agent Framework backend.
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
`proxy` means this service accepts only an already-built `GatewayRequest` at `/gateway/message` and forwards it after validation.
## Recommended enterprise setup
In `channel_gateway/.env`:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
```
In `agent_template_backend/.env`:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
This means:
```text
channel_gateway:7000 = understands channel payloads and builds GatewayRequest
backend:8000 = accepts only GatewayRequest and does not parse native channel payloads
```
## Run
```bash
cd channel_gateway
cp .env.example .env
uvicorn app.main:app --host 0.0.0.0 --port 7000
```
## Test web adapter endpoint
```bash
curl -s -X POST "http://localhost:7000/channels/web/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "external-gw-test-001",
"user_id": "user-external-001",
"message_id": "msg-external-001",
"customer_key": "11999999999",
"contract_key": "3000131180",
"interaction_key": "301953872",
"session_key": "external-gw-test-001"
}' | jq
```
## Test proxy mode
Set:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
Then call:
```bash
curl -s -X POST "http://localhost:7000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "proxy-test-001"
}
}' | jq
```
## Important distinction
Do not use `CHANNEL_GATEWAY_MODE=external` to mean “this service is external”.
Use:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
for the external gateway service that owns adapters.
Use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
in the Agent Framework backend when the backend must accept only normalized `GatewayRequest` payloads.

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Protocol
from app.schemas import GatewayRequest
class ChannelAdapter(Protocol):
name: str
async def to_gateway_request(self, payload) -> GatewayRequest: ...

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, VoiceTranscript
from app.settings import settings
class VoiceAdapter:
name = 'voice'
async def to_gateway_request(self, payload: VoiceTranscript) -> GatewayRequest:
session_id = payload.session_id or payload.call_id
message_id = payload.message_id or (f'{payload.call_id}-turn-1' if payload.call_id else None)
bc = BusinessContext(
customer_key=payload.customer_key or payload.caller,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or payload.call_id,
session_key=session_id,
metadata={
'source_channel': 'voice',
'confidence': payload.confidence,
'language': payload.language,
**(payload.metadata or {}),
},
)
data = {
'message': payload.transcript,
'session_id': session_id,
'user_id': payload.caller,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'voice',
'call_id': payload.call_id,
'confidence': payload.confidence,
'language': payload.language,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='voice', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WebMessage
from app.settings import settings
class WebAdapter:
name = 'web'
async def to_gateway_request(self, payload: WebMessage) -> GatewayRequest:
bc = payload.business_context or BusinessContext(
customer_key=payload.customer_key,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key,
account_key=payload.account_key,
resource_key=payload.resource_key,
session_key=payload.session_key or payload.session_id,
metadata={'source_channel': 'web', **(payload.metadata or {})},
)
data = payload.model_dump(exclude_none=True)
data['business_context'] = bc.model_dump(exclude_none=True)
data.setdefault('session_key', payload.session_key or payload.session_id)
data.setdefault('metadata', {})
data['metadata'] = {
**(data.get('metadata') or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'web',
'contract_version': 'gateway-request-v1',
}
return GatewayRequest(
channel='web',
tenant_id=settings.DEFAULT_TENANT_ID,
agent_id=settings.DEFAULT_AGENT_ID,
payload=data,
)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WhatsAppWebhook
from app.settings import settings
class WhatsAppAdapter:
name = 'whatsapp'
async def to_gateway_request(self, payload: WhatsAppWebhook) -> GatewayRequest:
user_id = payload.wa_id or payload.from_
text = payload.message or payload.text or payload.interactive_title or payload.interactive_id
if not text:
raise ValueError('INVALID_WHATSAPP_PAYLOAD: message/text/interactive_title is required')
session_id = payload.session_id or user_id
message_id = payload.message_id or payload.interaction_key
bc = BusinessContext(
customer_key=payload.customer_key or user_id,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or message_id,
session_key=session_id,
metadata={'source_channel': 'whatsapp', **(payload.metadata or {})},
)
data = {
'message': text,
'session_id': session_id,
'user_id': user_id,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'whatsapp',
'interactive_id': payload.interactive_id,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='whatsapp', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import httpx
from .settings import settings
from .schemas import GatewayRequest
class AgentFrameworkClient:
def __init__(self):
self.url = settings.AGENT_FRAMEWORK_BASE_URL.rstrip('/') + settings.AGENT_FRAMEWORK_GATEWAY_PATH
async def send(self, request: GatewayRequest) -> dict:
headers = {'Content-Type': 'application/json'}
if settings.INTERNAL_GATEWAY_TOKEN:
headers['X-Channel-Gateway-Token'] = settings.INTERNAL_GATEWAY_TOKEN
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT_SECONDS) as client:
resp = await client.post(self.url, json=request.model_dump(exclude_none=True), headers=headers)
try:
data = resp.json()
except Exception:
data = {'text': resp.text}
if resp.status_code >= 400:
return {
'ok': False,
'status_code': resp.status_code,
'error': data,
'forwarded_to': self.url,
}
if isinstance(data, dict):
data.setdefault('ok', True)
data.setdefault('forwarded_to', self.url)
return data
return {'ok': True, 'data': data, 'forwarded_to': self.url}

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from app.adapters.web import WebAdapter
from app.adapters.whatsapp import WhatsAppAdapter
from app.adapters.voice import VoiceAdapter
from app.client import AgentFrameworkClient
from app.schemas import GatewayRequest, VoiceTranscript, WebMessage, WhatsAppWebhook
from app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger('external_channel_gateway')
app = FastAPI(title='External Channel Gateway')
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(',')],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
client = AgentFrameworkClient()
web_adapter = WebAdapter()
whatsapp_adapter = WhatsAppAdapter()
voice_adapter = VoiceAdapter()
def _require_mode(expected: str, endpoint_type: str):
if settings.runtime_mode != expected:
raise HTTPException(
status_code=409,
detail={
'error_code': 'CHANNEL_GATEWAY_MODE_MISMATCH',
'message': f'{endpoint_type} endpoints require CHANNEL_GATEWAY_RUNTIME_MODE={expected}',
'current_runtime_mode': settings.runtime_mode,
},
)
async def _forward(request: GatewayRequest) -> dict:
return await client.send(request)
@app.get('/health')
async def health():
return {
'status': 'ok',
'app_name': settings.APP_NAME,
'runtime_mode': settings.runtime_mode,
'configured_runtime_mode': settings.CHANNEL_GATEWAY_RUNTIME_MODE,
'legacy_channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE,
'backend_url': client.url,
'default_tenant_id': settings.DEFAULT_TENANT_ID,
'default_agent_id': settings.DEFAULT_AGENT_ID,
}
@app.post('/channels/web/message')
async def web_message(payload: WebMessage, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await web_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/web/message',
})
return await _forward(gateway_request)
@app.post('/channels/whatsapp/webhook')
async def whatsapp_webhook(payload: WhatsAppWebhook, request: Request):
_require_mode('adapter', '/channels/*')
try:
gateway_request = await whatsapp_adapter.to_gateway_request(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/whatsapp/webhook',
})
return await _forward(gateway_request)
@app.post('/channels/voice/transcript')
async def voice_transcript(payload: VoiceTranscript, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await voice_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/voice/transcript',
})
return await _forward(gateway_request)
@app.post('/gateway/message')
async def proxy_gateway_message(payload: GatewayRequest, request: Request):
_require_mode('proxy', '/gateway/message')
payload.payload.setdefault('metadata', {})
payload.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/gateway/message',
'proxied_by': settings.APP_NAME,
})
return await _forward(payload)

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class BusinessContext(BaseModel):
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
protocol_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WebMessage(BaseModel):
message: str
session_id: str | None = None
user_id: str | None = None
message_id: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
business_context: BusinessContext | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WhatsAppWebhook(BaseModel):
wa_id: str | None = None
from_: str | None = Field(default=None, alias="from")
message: str | None = None
text: str | None = None
session_id: str | None = None
message_id: str | None = None
interactive_id: str | None = None
interactive_title: str | None = None
raw: dict[str, Any] = Field(default_factory=dict)
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class VoiceTranscript(BaseModel):
transcript: str
call_id: str | None = None
caller: str | None = None
session_id: str | None = None
message_id: str | None = None
confidence: float | None = None
language: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class GatewayRequest(BaseModel):
channel: str
payload: dict[str, Any]
agent_id: str | None = None
tenant_id: str | None = None
class GatewayResponse(BaseModel):
channel: str | None = None
session_id: str | None = None
text: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Literal
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
load_dotenv(override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
APP_NAME: str = 'external-channel-gateway'
LOG_LEVEL: str = 'INFO'
API_HOST: str = '0.0.0.0'
API_PORT: int = 7000
CORS_ORIGINS: str = 'http://localhost:5173,http://127.0.0.1:5173'
# adapter = receive native/simple channel payloads and translate them to GatewayRequest.
# proxy = receive only GatewayRequest and forward it after validation.
CHANNEL_GATEWAY_RUNTIME_MODE: Literal['adapter','proxy'] = 'adapter'
# Legacy alias accepted only for compatibility. Prefer CHANNEL_GATEWAY_RUNTIME_MODE.
CHANNEL_GATEWAY_MODE: str | None = None
AGENT_FRAMEWORK_BASE_URL: str = 'http://localhost:8000'
AGENT_FRAMEWORK_GATEWAY_PATH: str = '/gateway/message'
DEFAULT_TENANT_ID: str = 'default'
DEFAULT_AGENT_ID: str = 'telecom_contas'
REQUEST_TIMEOUT_SECONDS: float = 120.0
INTERNAL_GATEWAY_TOKEN: str | None = None
@property
def runtime_mode(self) -> str:
legacy = (self.CHANNEL_GATEWAY_MODE or '').strip().lower()
if legacy in {'adapter', 'proxy'}:
return legacy
# Legacy mapping for old deployments: embedded meant adapters on.
if legacy == 'embedded':
return 'adapter'
if legacy == 'external':
return 'proxy'
return self.CHANNEL_GATEWAY_RUNTIME_MODE
settings = Settings()

View File

@@ -0,0 +1,13 @@
channels:
web:
enabled: true
endpoint: /channels/web/message
adapter: web
whatsapp:
enabled: true
endpoint: /channels/whatsapp/webhook
adapter: whatsapp
voice:
enabled: true
endpoint: /channels/voice/transcript
adapter: voice

View File

@@ -0,0 +1,7 @@
services:
channel_gateway:
build: .
env_file:
- .env
ports:
- "7000:7000"

View File

@@ -0,0 +1,6 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-dotenv>=1.0.0

View File

@@ -0,0 +1,3 @@
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
MCP_GATEWAY_HOST=0.0.0.0
MCP_GATEWAY_PORT=8300

View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY apps/mcp_gateway/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY apps/mcp_gateway/app /app/app
COPY apps/mcp_gateway/config /app/config
ENV MCP_GATEWAY_CONFIG_PATH=/app/config/mcp_gateway.yaml
EXPOSE 8300
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8300"]

View File

@@ -0,0 +1,13 @@
# MCP Gateway
Camada desacoplada para descoberta, roteamento, controle e observabilidade de MCP Servers.
## Rotas
- `GET /health`
- `GET /tools`
- `POST /tools/{tool_name}/invoke`
## Configuração
O arquivo `config/mcp_servers.yaml` define os servidores e ferramentas expostas.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,424 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any
import httpx
import yaml
from fastapi import FastAPI, Header, HTTPException
from agent_framework.security import install_authentication
from pydantic import BaseModel, Field
class BusinessContext(BaseModel):
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ToolInvocation(BaseModel):
tenant_id: str = "default"
agent_id: str
channel: str | None = None
tool_name: str
arguments: dict[str, Any] = Field(default_factory=dict)
business_context: BusinessContext = Field(default_factory=BusinessContext)
metadata: dict[str, Any] = Field(default_factory=dict)
class ToolResult(BaseModel):
tool_name: str
version: str | None = None
ok: bool
data: Any = None
error: str | None = None
cache: dict[str, Any] = Field(default_factory=dict)
latency_ms: int = 0
metadata: dict[str, Any] = Field(default_factory=dict)
class DiscoveryResult(BaseModel):
ok: bool
servers_scanned: int = 0
tools_discovered: int = 0
errors: list[dict[str, Any]] = Field(default_factory=list)
tools: list[str] = Field(default_factory=list)
def load_config() -> dict[str, Any]:
path = Path(os.getenv("MCP_GATEWAY_CONFIG_PATH", "config/mcp_gateway.yaml"))
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
config = load_config()
static_tools: dict[str, dict[str, Any]] = dict(config.get("tools") or {})
discovered_tools: dict[str, dict[str, Any]] = {}
discovery_state: dict[str, Any] = {"last_sync": None, "errors": [], "tools": []}
cache: dict[str, tuple[float, Any]] = {}
app = FastAPI(title="Agent Platform OCI - MCP Gateway", version="1.1.0")
install_authentication(app, prefix="MCP_GATEWAY_AUTH")
def audit(name: str, payload: dict[str, Any]) -> None:
print(json.dumps({"event": name, **payload}, ensure_ascii=False, default=str))
def auth_check(authorization: str | None) -> None:
auth = config.get("auth") or {}
if not auth.get("enabled", False):
return
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing MCP Gateway bearer token")
token = authorization.split(" ", 1)[1]
if token not in (auth.get("static_tokens") or {}):
raise HTTPException(status_code=403, detail="Invalid MCP Gateway token")
def all_tools() -> dict[str, dict[str, Any]]:
merged = dict(discovered_tools)
# Static config wins over discovery so operators can override metadata safely.
merged.update(static_tools)
return merged
def map_arguments(tool_name: str, args: dict[str, Any], bc: dict[str, Any]) -> dict[str, Any]:
result = dict(args or {})
for source, target in ((config.get("parameter_mapping") or {}).get(tool_name) or {}).items():
if bc.get(source) is not None and target not in result:
result[target] = bc[source]
return result
def cache_key(tenant_id: str, agent_id: str, tool_name: str, version: str, args: dict[str, Any]) -> str:
digest = hashlib.sha256(json.dumps(args, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
return f"mcp:{tenant_id}:{agent_id}:{tool_name}:{version}:{digest}"
async def post_with_retry(url: str, payload: dict[str, Any], timeout: int, retry: dict[str, Any]) -> Any:
attempts = int(retry.get("max_attempts", 1)) if retry.get("enabled", False) else 1
backoff_ms = int(retry.get("backoff_ms", 250))
last_exc: Exception | None = None
for attempt in range(attempts):
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
return resp.json()
except Exception as exc:
last_exc = exc
if attempt < attempts - 1:
await asyncio.sleep(backoff_ms / 1000)
raise RuntimeError(str(last_exc))
async def get_json(url: str, timeout: int) -> Any:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
def build_server_payload(tool_name: str, args: dict[str, Any], server: dict[str, Any], tool: dict[str, Any]) -> dict[str, Any]:
"""Build the payload expected by the downstream MCP server.
Supported protocols:
- legacy_http/framework_http: POST /mcp/tools/call with {tool_name, arguments}
- direct_http: POST to the configured endpoint with only the argument object
"""
protocol = str(tool.get("protocol") or server.get("protocol") or "legacy_http")
if protocol in {"legacy_http", "framework_http", "fastmcp_http"}:
return {"tool_name": tool_name, "arguments": args or {}}
return args or {}
def normalize_server_response(data: Any) -> tuple[bool, Any, str | None, dict[str, Any]]:
if isinstance(data, dict) and ("ok" in data or "result" in data or "error" in data):
ok = bool(data.get("ok", not data.get("error")))
return ok, data.get("result", data.get("data")), data.get("error"), data.get("metadata") or {}
return True, data, None, {}
def _tool_name(raw: dict[str, Any]) -> str | None:
return raw.get("name") or raw.get("tool_name") or raw.get("id")
def _tool_schema(raw: dict[str, Any]) -> dict[str, Any]:
schema = raw.get("input_schema") or raw.get("inputSchema") or raw.get("schema") or {}
if isinstance(schema, dict):
return schema
return {}
def _extract_tools_from_catalog(catalog: Any) -> list[dict[str, Any]]:
"""Normalize common MCP/FastMCP/custom catalog shapes.
Accepted examples:
- {"tools": [{"name": "x", "description": "...", "input_schema": {...}}]}
- [{"name": "x", "inputSchema": {...}}]
- {"server_id": "s", "capabilities": {"tools": [...]}}
- {"data": {"tools": [...]}}
"""
if isinstance(catalog, list):
return [x for x in catalog if isinstance(x, dict)]
if not isinstance(catalog, dict):
return []
if isinstance(catalog.get("tools"), list):
return [x for x in catalog["tools"] if isinstance(x, dict)]
data = catalog.get("data")
if isinstance(data, dict) and isinstance(data.get("tools"), list):
return [x for x in data["tools"] if isinstance(x, dict)]
caps = catalog.get("capabilities")
if isinstance(caps, dict) and isinstance(caps.get("tools"), list):
return [x for x in caps["tools"] if isinstance(x, dict)]
return []
def _catalog_urls(server_id: str, server: dict[str, Any]) -> list[str]:
if server.get("manifest_url"):
return [str(server["manifest_url"])]
base = str(server.get("url", "")).rstrip("/")
if server.get("catalog_endpoint"):
return [f"{base}{server['catalog_endpoint']}"]
discovery = config.get("discovery") or {}
endpoints = server.get("discovery_endpoints") or discovery.get("default_catalog_endpoints") or [
"/.well-known/mcp-server.json",
"/manifest",
"/mcp/tools",
"/tools",
"/v1/tools",
]
return [f"{base}{endpoint}" for endpoint in endpoints]
def _endpoint_for_tool(server: dict[str, Any], raw_tool: dict[str, Any]) -> str:
if raw_tool.get("endpoint"):
return str(raw_tool["endpoint"])
protocol = str(raw_tool.get("protocol") or server.get("protocol") or "legacy_http")
if protocol in {"legacy_http", "framework_http", "fastmcp_http"}:
return str(server.get("invoke_endpoint") or "/tools/call")
name = _tool_name(raw_tool) or ""
return str(server.get("invoke_endpoint") or f"/tools/{name}")
def normalize_discovered_tool(server_id: str, server: dict[str, Any], raw_tool: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
name = _tool_name(raw_tool)
if not name:
return None
discovery = config.get("discovery") or {}
defaults = discovery.get("tool_defaults") or {}
tool_cfg: dict[str, Any] = {
"version": str(raw_tool.get("version") or defaults.get("version") or "1.0.0"),
"server": server_id,
"endpoint": _endpoint_for_tool(server, raw_tool),
"protocol": raw_tool.get("protocol") or server.get("protocol") or defaults.get("protocol") or "legacy_http",
"enabled": bool(raw_tool.get("enabled", defaults.get("enabled", True))),
"idempotent": bool(raw_tool.get("idempotent", defaults.get("idempotent", True))),
"cache_ttl_seconds": int(raw_tool.get("cache_ttl_seconds", defaults.get("cache_ttl_seconds", 0)) or 0),
"timeout_seconds": int(raw_tool.get("timeout_seconds", server.get("timeout_seconds", defaults.get("timeout_seconds", 30))) or 30),
"retry": raw_tool.get("retry") or defaults.get("retry") or {"enabled": False},
"allowed_agents": raw_tool.get("allowed_agents") or defaults.get("allowed_agents") or [],
"allowed_channels": raw_tool.get("allowed_channels") or defaults.get("allowed_channels") or [],
"required_business_keys": raw_tool.get("required_business_keys") or defaults.get("required_business_keys") or [],
"description": raw_tool.get("description") or raw_tool.get("doc") or "",
"input_schema": _tool_schema(raw_tool),
"source": "discovery",
}
return name, tool_cfg
async def discover_server(server_id: str, server: dict[str, Any]) -> tuple[list[tuple[str, dict[str, Any]]], list[dict[str, Any]]]:
errors: list[dict[str, Any]] = []
if not server.get("enabled", True):
return [], []
if not server.get("discover", False):
return [], []
timeout = int((config.get("discovery") or {}).get("timeout_seconds", server.get("timeout_seconds", 10)) or 10)
for url in _catalog_urls(server_id, server):
try:
catalog = await get_json(url, timeout=timeout)
raw_tools = _extract_tools_from_catalog(catalog)
normalized = []
for raw in raw_tools:
item = normalize_discovered_tool(server_id, server, raw)
if item:
normalized.append(item)
if normalized:
return normalized, errors
errors.append({"server": server_id, "url": url, "error": "catalog returned no tools"})
except Exception as exc:
errors.append({"server": server_id, "url": url, "error": str(exc)})
return [], errors
async def sync_discovery() -> DiscoveryResult:
discovered_tools.clear()
errors: list[dict[str, Any]] = []
tools_count = 0
scanned = 0
for server_id, server in (config.get("servers") or {}).items():
if not isinstance(server, dict) or not server.get("discover", False):
continue
scanned += 1
normalized, server_errors = await discover_server(server_id, server)
errors.extend(server_errors)
for name, tool_cfg in normalized:
discovered_tools[name] = tool_cfg
tools_count += 1
discovery_state.update({
"last_sync": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"errors": errors,
"tools": sorted(discovered_tools.keys()),
})
audit("mcp.discovery.completed", {"servers_scanned": scanned, "tools_discovered": tools_count, "errors": len(errors)})
return DiscoveryResult(ok=not errors, servers_scanned=scanned, tools_discovered=tools_count, errors=errors, tools=sorted(discovered_tools.keys()))
@app.on_event("startup")
async def startup_discovery() -> None:
discovery = config.get("discovery") or {}
if discovery.get("enabled", False) and discovery.get("sync_on_startup", True):
try:
await sync_discovery()
except Exception as exc:
audit("mcp.discovery.failed", {"error": str(exc)})
@app.get("/health")
async def health():
return {"status": "ok", "service": "mcp_gateway", "version": "1.1.0"}
@app.get("/ready")
async def ready():
enabled_tools = [k for k, v in all_tools().items() if v.get("enabled", True)]
return {"status": "ready", "tools": enabled_tools, "discovery": discovery_state}
@app.get("/v1/tools")
async def tools():
return {"tools": [{"name": name, **cfg} for name, cfg in all_tools().items()]}
@app.get("/v1/tools/{tool_name}")
async def tool_detail(tool_name: str):
tool = all_tools().get(tool_name)
if not tool:
raise HTTPException(status_code=404, detail=f"Tool not found: {tool_name}")
return {"name": tool_name, **tool}
@app.get("/v1/discovery/servers")
async def discovery_servers():
servers = []
for server_id, server in (config.get("servers") or {}).items():
servers.append({
"id": server_id,
"enabled": server.get("enabled", True),
"discover": server.get("discover", False),
"url": server.get("url"),
"manifest_url": server.get("manifest_url"),
"catalog_endpoint": server.get("catalog_endpoint"),
})
return {"servers": servers, "state": discovery_state}
@app.post("/v1/discovery/sync", response_model=DiscoveryResult)
async def discovery_sync(authorization: str | None = Header(default=None)):
auth_check(authorization)
return await sync_discovery()
@app.post("/v1/tools/{tool_name}/invoke", response_model=ToolResult)
async def invoke(tool_name: str, invocation: ToolInvocation, authorization: str | None = Header(default=None)):
started = time.perf_counter()
auth_check(authorization)
tool = all_tools().get(tool_name)
if not tool or not tool.get("enabled", True):
raise HTTPException(status_code=404, detail=f"Tool not found or disabled: {tool_name}")
if invocation.tool_name != tool_name:
raise HTTPException(status_code=422, detail="Path tool_name and body tool_name differ")
allowed_agents = tool.get("allowed_agents") or []
if allowed_agents and invocation.agent_id not in allowed_agents:
raise HTTPException(status_code=403, detail=f"Agent not allowed: {invocation.agent_id}")
allowed_channels = tool.get("allowed_channels") or []
if invocation.channel and allowed_channels and invocation.channel not in allowed_channels:
raise HTTPException(status_code=403, detail=f"Channel not allowed: {invocation.channel}")
bc = invocation.business_context.model_dump()
missing = [k for k in tool.get("required_business_keys", []) if not bc.get(k)]
if missing:
raise HTTPException(status_code=422, detail={"missing_business_keys": missing})
version = str(tool.get("version", "1.0.0"))
args = map_arguments(tool_name, invocation.arguments, bc)
ttl = int(tool.get("cache_ttl_seconds", 0) or 0)
ck = None
if tool.get("idempotent", False) and ttl > 0:
ck = cache_key(invocation.tenant_id, invocation.agent_id, tool_name, version, args)
cached = cache.get(ck)
if cached and cached[0] > time.time():
audit("mcp.cache.hit", {"tool": tool_name, "agent_id": invocation.agent_id})
return ToolResult(
tool_name=tool_name,
version=version,
ok=True,
data=cached[1],
cache={"hit": True, "key": ck, "ttl_seconds": ttl},
latency_ms=int((time.perf_counter() - started) * 1000),
)
server = (config.get("servers") or {}).get(tool.get("server"))
if not server or not server.get("enabled", True):
raise HTTPException(status_code=503, detail=f"MCP server unavailable: {tool.get('server')}")
url = f"{server['url'].rstrip('/')}{tool.get('endpoint')}"
audit("mcp.tool.started", {"tool": tool_name, "version": version, "agent_id": invocation.agent_id, "server": tool.get("server")})
try:
raw_data = await post_with_retry(
url=url,
payload=build_server_payload(tool_name, args, server, tool),
timeout=int(tool.get("timeout_seconds") or server.get("timeout_seconds") or 30),
retry=tool.get("retry") or {},
)
ok, data, error, server_metadata = normalize_server_response(raw_data)
if ck and ttl > 0 and ok:
cache[ck] = (time.time() + ttl, data)
latency_ms = int((time.perf_counter() - started) * 1000)
audit("mcp.tool.completed", {"tool": tool_name, "latency_ms": latency_ms, "ok": ok})
return ToolResult(
tool_name=tool_name,
version=version,
ok=ok,
data=data,
error=error,
cache={"hit": False, "key": ck, "ttl_seconds": ttl},
latency_ms=latency_ms,
metadata={"server": tool.get("server"), "source": tool.get("source", "static"), **server_metadata},
)
except Exception as exc:
latency_ms = int((time.perf_counter() - started) * 1000)
audit("mcp.tool.failed", {"tool": tool_name, "latency_ms": latency_ms, "error": str(exc)})
return ToolResult(
tool_name=tool_name,
version=version,
ok=False,
error=str(exc),
latency_ms=latency_ms,
metadata={"server": tool.get("server"), "source": tool.get("source", "static")},
)

View File

@@ -0,0 +1,39 @@
# Nunca coloque secrets diretamente neste arquivo. Use sempre *_env.
providers:
public:
mode: none
deny:
mode: deny
tia_basic:
mode: basic
client_id_env: TIA_AGENT_CLIENT_ID
secret_hash_env: TIA_AGENT_SECRET_HASH
realm: agent-contas
platform_jwt:
mode: jwt
key_env: PLATFORM_JWT_PUBLIC_KEY
algorithms: [RS256]
audience: agent-platform
issuer: https://identity.example.com/
policies:
- name: health-public
provider: public
paths: [/health, /ready, /live]
- name: tia-agent-api
provider: tia_basic
paths: [/gateway/message, /gateway/message/sse, /gateway/events/*]
methods: [GET, POST]
- name: admin-api
provider: platform_jwt
paths: [/debug/*, /admin/*]
required_roles: [platform-admin]
required_scopes: [agent.admin]
# Quando nenhuma política casar, rejeita. O default omitido também é deny.
default_provider: deny

View File

@@ -0,0 +1,216 @@
# Dedicated MCP Gateway configuration.
# The agent backend/framework calls this gateway; this gateway calls the final MCP servers.
# Discovery allows the gateway to sync tool catalogs from registered MCP servers.
# Static tools below remain supported and override discovered tools with the same name.
discovery:
enabled: true
sync_on_startup: true
timeout_seconds: 10
default_catalog_endpoints:
- /.well-known/mcp-server.json
- /manifest
- /mcp/tools
- /tools/list
- /tools
- /v1/tools
tool_defaults:
version: 1.0.0
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
servers:
telecom:
enabled: true
discover: false
protocol: legacy_http
transport: http
# Local run: uvicorn mcp.servers.telecom_mcp_server.main:app --port 8100
url: http://localhost:8100/mcp
timeout_seconds: 30
retail:
enabled: true
discover: false
protocol: legacy_http
transport: http
# Local run: uvicorn mcp.servers.retail_mcp_server.main:app --port 8200
url: http://localhost:8200/mcp
timeout_seconds: 30
tools:
consultar_fatura:
version: 1.0.0
server: telecom
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
consultar_pagamentos:
version: 1.0.0
server: telecom
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
consultar_plano:
version: 1.0.0
server: telecom
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
listar_servicos:
version: 1.0.0
server: telecom
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
consultar_pedido:
version: 1.0.0
server: retail
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
consultar_entrega:
version: 1.0.0
server: retail
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: true
cache_ttl_seconds: 300
timeout_seconds: 30
retry: {enabled: true, max_attempts: 2, backoff_ms: 250}
allowed_agents: []
allowed_channels: []
required_business_keys: []
solicitar_troca:
version: 1.0.0
server: retail
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: false
cache_ttl_seconds: 0
timeout_seconds: 30
retry: {enabled: false}
allowed_agents: []
allowed_channels: []
required_business_keys: []
solicitar_devolucao:
version: 1.0.0
server: retail
endpoint: /tools/call
protocol: legacy_http
enabled: true
idempotent: false
cache_ttl_seconds: 0
timeout_seconds: 30
retry: {enabled: false}
allowed_agents: []
allowed_channels: []
required_business_keys: []
# Optional mapping if the backend sends canonical BusinessContext directly to the gateway.
# In the normal framework path, the framework already maps before calling the gateway.
parameter_mapping:
consultar_fatura:
customer_key: msisdn
contract_key: invoice_id
interaction_key: ura_call_id
session_key: session_id
consultar_pagamentos:
customer_key: msisdn
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
customer_key: msisdn
resource_key: asset_id
contract_key: asset_id
session_key: session_id
listar_servicos:
customer_key: msisdn
session_key: session_id
consultar_pedido:
customer_key: customer_id
contract_key: order_id
session_key: session_id
consultar_entrega:
contract_key: order_id
session_key: session_id
solicitar_troca:
contract_key: order_id
session_key: session_id
solicitar_devolucao:
contract_key: order_id
session_key: session_id
auth:
enabled: false
static_tokens:
runtime-local-token:
agents: []
# Example of a dynamically discovered MCP server.
# Uncomment and adjust the URL to plug a new server that exposes a catalog/manifest.
# servers:
# nf_items:
# enabled: true
# discover: true
# protocol: legacy_http
# transport: http
# url: http://localhost:8400/mcp
# # Optional. If omitted, the gateway tries the discovery.default_catalog_endpoints.
# # manifest_url: http://localhost:8400/.well-known/mcp-server.json
# # catalog_endpoint: /tools
# # invoke_endpoint: /tools/call
# timeout_seconds: 30

View File

@@ -0,0 +1,15 @@
servers:
telecom:
base_url: http://telecom-mcp-server:8101
tools:
- consultar_fatura
- consultar_pagamentos
- consultar_plano
- listar_servicos
retail:
base_url: http://retail-mcp-server:8102
tools:
- consultar_pedido
- consultar_entrega
- solicitar_troca
- solicitar_devolucao

View File

@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.6
PyYAML>=6.0
httpx>=0.27