Projeto do Agent Contas ORACLE
This commit is contained in:
35
agent_framework_oci/apps/agent_gateway/.env.example
Normal file
35
agent_framework_oci/apps/agent_gateway/.env.example
Normal 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
|
||||
6
agent_framework_oci/apps/agent_gateway/Dockerfile
Normal file
6
agent_framework_oci/apps/agent_gateway/Dockerfile
Normal 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"]
|
||||
73
agent_framework_oci/apps/agent_gateway/README.md
Normal file
73
agent_framework_oci/apps/agent_gateway/README.md
Normal 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
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
328
agent_framework_oci/apps/agent_gateway/app/main.py
Normal file
328
agent_framework_oci/apps/agent_gateway/app/main.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
62
agent_framework_oci/apps/agent_gateway/app/settings.py
Normal file
62
agent_framework_oci/apps/agent_gateway/app/settings.py
Normal 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()
|
||||
@@ -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
|
||||
38
agent_framework_oci/apps/agent_gateway/config/backends.yaml
Normal file
38
agent_framework_oci/apps/agent_gateway/config/backends.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
7
agent_framework_oci/apps/agent_gateway/requirements.txt
Normal file
7
agent_framework_oci/apps/agent_gateway/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user