First commit
This commit is contained in:
192
apps/agent_frontend/app.js
Normal file
192
apps/agent_frontend/app.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const chat = document.getElementById("chat");
|
||||
const form = document.getElementById("form");
|
||||
let eventSource = null;
|
||||
let currentSessionId = null;
|
||||
const renderedResponses = new Set();
|
||||
|
||||
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 humanizeAgentName(value) {
|
||||
return String(value || "agent").trim().replace(/([a-zà-ÿ])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
function responseText(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
function splitTaggedAgentMessages(value) {
|
||||
const text = responseText(value).trim();
|
||||
if (!text) return [];
|
||||
const marker = /^\[([^\]\r\n]*Agent)\]\s*/gim;
|
||||
const matches = [...text.matchAll(marker)];
|
||||
if (!matches.length || matches[0].index !== 0) return [];
|
||||
return matches.map((match, index) => {
|
||||
const start = match.index + match[0].length;
|
||||
const end = index + 1 < matches.length ? matches[index + 1].index : text.length;
|
||||
return { agent: match[1].trim(), text: text.slice(start, end).trim() };
|
||||
}).filter((item) => item.agent && item.text);
|
||||
}
|
||||
function stripOwnAgentMarker(value, agent) {
|
||||
const text = responseText(value).trim();
|
||||
const marker = text.match(/^\[([^\]\r\n]*Agent)\]\s*/i);
|
||||
if (!marker) return text;
|
||||
const normalize = (name) => String(name || "").replace(/[_\s-]+/g, "").toLowerCase();
|
||||
return normalize(marker[1]) === normalize(agent) ? text.slice(marker[0].length).trim() : text;
|
||||
}
|
||||
function orderAgentMessages(agentResponses) {
|
||||
return [...agentResponses].sort(
|
||||
(a, b) => Number(Boolean(a.primary)) - Number(Boolean(b.primary))
|
||||
);
|
||||
}
|
||||
function sameAgent(left, right) {
|
||||
const normalize = (name) => String(name || "").replace(/[_\s-]+/g, "").toLowerCase();
|
||||
return Boolean(normalize(left)) && normalize(left) === normalize(right);
|
||||
}
|
||||
function addUserMessage(text) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg user chat-bubble--user";
|
||||
div.textContent = text;
|
||||
chat.appendChild(div);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
function addAgentMessage(agentId, text, group) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "agent-message";
|
||||
article.dataset.agentId = agentId || "agent";
|
||||
const header = document.createElement("header");
|
||||
header.className = "agent-message__header";
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "agent-message__avatar";
|
||||
avatar.textContent = humanizeAgentName(agentId).charAt(0) || "A";
|
||||
avatar.setAttribute("aria-hidden", "true");
|
||||
const name = document.createElement("span");
|
||||
name.className = "agent-message__name";
|
||||
name.textContent = humanizeAgentName(agentId);
|
||||
const body = document.createElement("div");
|
||||
body.className = "agent-message__body";
|
||||
body.textContent = responseText(text);
|
||||
header.append(avatar, name);
|
||||
article.append(header, body);
|
||||
group.appendChild(article);
|
||||
}
|
||||
function getAgentMessages(data) {
|
||||
const metadata = data?.metadata || {};
|
||||
const candidates = data?.supervisor_results || metadata.supervisor_results || data?.agent_responses || metadata.agent_responses || (Array.isArray(data?.messages) ? data.messages : null);
|
||||
if (Array.isArray(candidates)) {
|
||||
const messages = candidates.map((item) => ({
|
||||
agent: item?.agent || item?.agent_id || item?.agentId || item?.name,
|
||||
text: stripOwnAgentMarker(item?.answer ?? item?.text ?? item?.message ?? item?.response ?? item?.content, item?.agent || item?.agent_id || item?.agentId || item?.name),
|
||||
primary: item?.primary === true,
|
||||
})).filter((item) => item.agent && responseText(item.text).trim());
|
||||
if (messages.length) return orderAgentMessages(messages);
|
||||
}
|
||||
const text = data?.text ?? data?.speak ?? data?.message ?? data?.response ?? data?.content ?? data?.output;
|
||||
const taggedMessages = splitTaggedAgentMessages(text);
|
||||
if (taggedMessages.length) {
|
||||
const primaryAgent = metadata.active_agent || metadata.route || data?.route || metadata.route_decision?.agent || metadata.route_decision?.route;
|
||||
return orderAgentMessages(taggedMessages.map((item) => ({
|
||||
...item,
|
||||
primary: sameAgent(item.agent, primaryAgent),
|
||||
})));
|
||||
}
|
||||
const agent = metadata.active_agent || metadata.route || data?.agent_id || metadata.agent_id || val("agent") || "agent";
|
||||
return responseText(text).trim() ? [{ agent, text }] : [];
|
||||
}
|
||||
function responseKey(data) {
|
||||
const stableId = data?.metadata?.message_id || data?.message_id;
|
||||
return stableId ? `id:${stableId}` : JSON.stringify(getAgentMessages(data));
|
||||
}
|
||||
function renderAgentResponse(data) {
|
||||
const normalized = typeof data === "string" ? { text: data } : (data || {});
|
||||
const messages = getAgentMessages(normalized);
|
||||
if (!messages.length) return false;
|
||||
const key = responseKey(normalized);
|
||||
if (renderedResponses.has(key)) return false;
|
||||
renderedResponses.add(key);
|
||||
const group = document.createElement("section");
|
||||
group.className = "agent-response-group";
|
||||
group.setAttribute("aria-label", "Respostas dos agentes");
|
||||
messages.forEach((message) => addAgentMessage(message.agent, message.text, group));
|
||||
chat.appendChild(group);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
if (val("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 normalizeSessionId(value) {
|
||||
if (!value) return uuid();
|
||||
const parts = value.split(":"); return parts[parts.length - 1];
|
||||
}
|
||||
function connectSSE(backend, sessionId) {
|
||||
if (!backend || !sessionId) return;
|
||||
if (eventSource) eventSource.close();
|
||||
const url = `${backend.replace(/\/$/, "")}/gateway/events/${encodeURIComponent(sessionId)}`;
|
||||
eventSource = new EventSource(url);
|
||||
eventSource.onopen = () => status("SSE conectado");
|
||||
eventSource.onerror = () => {
|
||||
if (eventSource?.readyState === EventSource.CONNECTING) status("SSE aguardando/reconectando");
|
||||
else if (eventSource?.readyState === EventSource.CLOSED) status("SSE fechado");
|
||||
else status("SSE com erro");
|
||||
};
|
||||
const statuses = { waiting: "SSE aguardando backend", "backend.selected": "Backend selecionado", "flow.start": "Fluxo iniciado", "workflow.started": "Workflow em execução", "workflow.completed": "Workflow concluído", "flow.end": "Fluxo finalizado" };
|
||||
Object.entries(statuses).forEach(([eventName, label]) => eventSource.addEventListener(eventName, () => status(label)));
|
||||
eventSource.addEventListener("message.responded", (event) => {
|
||||
try { renderAgentResponse(JSON.parse(event.data)); } catch { renderAgentResponse(event.data); }
|
||||
status("Resposta recebida");
|
||||
});
|
||||
eventSource.addEventListener("server.error", (event) => {
|
||||
renderAgentResponse({ text: `Erro SSE: ${event.data || "erro informado pelo servidor"}`, agent_id: "sistema" });
|
||||
status("Erro no fluxo SSE");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById("message");
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
addUserMessage(text); input.value = "";
|
||||
const backend = val("backend").replace(/\/$/, "");
|
||||
const channel = val("channel");
|
||||
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, session_id: session, user_id: businessContext.customer_key || "web-user", message_id: messageId, tenant_id: tenantId, agent_id: agentId, context: commonContext };
|
||||
syncDomainAliases(payload, businessContext);
|
||||
if (document.getElementById("useSse")?.checked) connectSSE(backend, session);
|
||||
try {
|
||||
status("Enviando mensagem");
|
||||
const response = await fetch(`${backend}/gateway/message`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel, tenant_id: tenantId, agent_id: agentId, payload }) });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
const returnedSessionId = data.session_id || session;
|
||||
currentSessionId = returnedSessionId;
|
||||
document.getElementById("session").value = returnedSessionId;
|
||||
renderAgentResponse(data);
|
||||
status("Resposta recebida");
|
||||
} catch (error) {
|
||||
renderAgentResponse({ text: `Erro ao chamar backend: ${error.message}`, agent_id: "sistema" });
|
||||
status("Erro de conexão");
|
||||
}
|
||||
});
|
||||
49
apps/agent_frontend/index.html
Normal file
49
apps/agent_frontend/index.html
Normal 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>
|
||||
1
apps/agent_frontend/styles.css
Normal file
1
apps/agent_frontend/styles.css
Normal file
@@ -0,0 +1 @@
|
||||
body{font-family:system-ui,Arial,sans-serif;background:#f6f7fb;margin:0;color:#202124}.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{box-sizing:border-box;width:100%;padding:10px;border:1px solid #ccc;border-radius:8px}.chat{background:#fff;border:1px solid #ddd;border-radius:12px;min-height:420px;max-height:65vh;padding:16px;overflow:auto}.msg{padding:10px 12px;border-radius:10px;margin:8px 0;max-width:75%;white-space:pre-wrap}.user{background:#e8f0ff;margin-left:auto}.agent-response-group{display:grid;gap:10px;margin:12px 0;max-width:82%}.agent-message{overflow:hidden;border:1px solid #dfe3eb;border-radius:12px;background:#f8f9fb;box-shadow:0 1px 2px rgba(20,30,50,.05)}.agent-message__header{display:flex;align-items:center;gap:8px;margin:0;padding:8px 12px;border-bottom:1px solid #e5e8ee;background:#eef1f6}.agent-message__avatar{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:#263f73;color:#fff;font-size:12px;font-weight:700}.agent-message__name{font-size:13px;font-weight:700;color:#34425e}.agent-message__body{padding:11px 12px;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.45}form{display:flex;gap:8px;margin-top:12px}button{padding:10px 16px;border:0;border-radius:8px;background:#111;color:#fff;cursor:pointer}@media(max-width:720px){.app{padding:12px}.config{grid-template-columns:1fr}.agent-response-group,.msg{max-width:92%}}
|
||||
3
apps/agent_frontend_a2a/Dockerfile
Normal file
3
apps/agent_frontend_a2a/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:stable-alpine
|
||||
COPY index.html styles.css app.js a2a-client.js /usr/share/nginx/html/
|
||||
EXPOSE 80
|
||||
133
apps/agent_frontend_a2a/README.md
Normal file
133
apps/agent_frontend_a2a/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Frontend A2A para o Agent Framework OCI
|
||||
|
||||
Interface estática, sem build e sem dependências JavaScript de produção. Usa os
|
||||
endpoints A2A que já existem no pacote: pode falar com o template de backend
|
||||
ou com o Agent Gateway. O frontend nativo em `apps/agent_frontend` é preservado.
|
||||
|
||||
## Experimentar sem OCI
|
||||
|
||||
Na raiz do framework:
|
||||
|
||||
```sh
|
||||
python -m pip install -e 'libs/agent_framework[a2a]' uvicorn
|
||||
python -m uvicorn examples.a2a_frontend_demo:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Abra **http://localhost:8000** e clique em **Conectar ao serviço**. O exemplo
|
||||
serve a tela e a API na mesma origem, sem precisar configurar CORS.
|
||||
|
||||
A demonstração usa o **adaptador A2A e o SDK reais**, com a lógica de negócio
|
||||
simulada. Digite uma mensagem e acompanhe a tarefa. Comandos de demonstração:
|
||||
|
||||
- `/confirmar`: aguarda sua resposta e retoma a mesma Task.
|
||||
- `/lento`: permite testar o botão de cancelamento.
|
||||
- `/erro`: demonstra uma Task que termina em falha.
|
||||
|
||||
Se a porta 8000 estiver ocupada, altere a porta ao iniciar e informe o novo
|
||||
endereço no campo **Endereço do serviço**, ou use `?service=http://localhost:NOVA_PORTA`.
|
||||
|
||||
## Usar com seu agente ou Gateway
|
||||
|
||||
Habilite A2A no serviço, conforme `docs/A2A_ASSINCRONO.md`, e sirva este diretório:
|
||||
|
||||
```sh
|
||||
python -m http.server 5173 --directory apps/agent_frontend_a2a
|
||||
```
|
||||
|
||||
Abra **http://localhost:5173**. Não abra `index.html` como arquivo local, pois a
|
||||
interface usa módulos JavaScript e requisições HTTP.
|
||||
|
||||
| Uso | Endereço do serviço | Destino na interface | Seleção carregada do Agent Card |
|
||||
| --- | --- | --- | --- |
|
||||
| Template de agente | `http://localhost:8000` | Agente | Perfis de `agents.yaml` |
|
||||
| Agent Gateway | `http://localhost:8010` | Gateway | Backends do catálogo |
|
||||
|
||||
Use **Seleção automática** para deixar a decisão com o serviço. No Gateway,
|
||||
um backend selecionado é enviado como `message.metadata.backend_id`. Um perfil
|
||||
opcional dentro dele pode ser informado em **Perfil do agente no backend**.
|
||||
O adaptador A2A encaminha a seleção ao roteador existente; os contratos nativos
|
||||
não são alterados.
|
||||
|
||||
O serviço deve permitir a origem da interface em `CORS_ORIGINS`. O valor padrão
|
||||
do pacote já permite `http://localhost:5173`. Se mudar o host ou a porta,
|
||||
configure a origem exata, por exemplo:
|
||||
|
||||
```dotenv
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
ENABLE_A2A=true
|
||||
A2A_TASK_MODE=true
|
||||
```
|
||||
|
||||
Para publicar com Docker:
|
||||
|
||||
```sh
|
||||
docker build -t agent-frontend-a2a apps/agent_frontend_a2a
|
||||
docker run --rm -p 5173:80 agent-frontend-a2a
|
||||
```
|
||||
|
||||
O navegador precisa alcançar o endereço informado no formulário. Para uso
|
||||
externo, informe a URL pública do backend/Gateway, não o nome interno do
|
||||
container. Uma interface HTTPS deve acessar um serviço HTTPS.
|
||||
|
||||
## O que a interface oferece
|
||||
|
||||
- Descoberta do Agent Card e escolha de versão automática ou explícita (1.0/0.3).
|
||||
- Envio de mensagem com retorno imediato de Task.
|
||||
- Acompanhamento por POST SSE com `SubscribeToTask`/`tasks/resubscribe`.
|
||||
- Recuperação por `GetTask`/`tasks/get` quando o fluxo termina ou cai.
|
||||
- Consulta periódica como alternativa ao streaming.
|
||||
- Cancelamento solicitado ao serviço, incluindo tarefas aguardando input.
|
||||
- Continuação de tarefas em input-required/auth-required.
|
||||
- Exibição de respostas diretas e dos artefatos de Tasks, com suporte aos
|
||||
resultados estruturados de múltiplos agentes do framework.
|
||||
- Contexto de negócio opcional (cliente e contrato/pedido).
|
||||
- Identificadores da conversa/tarefa preservados em `sessionStorage` para
|
||||
retomar após recarregar a mesma aba. O conteúdo do chat não é persistido.
|
||||
- Nova conversa independente, sem reaproveitar uma Task concluída.
|
||||
|
||||
O frontend usa `fetch` para SSE, permitindo enviar os mesmos cabeçalhos de
|
||||
autenticação nas consultas e no acompanhamento. Em **Contexto e autenticação**,
|
||||
informe o nome e o valor completo do cabeçalho exigido pelo serviço, por
|
||||
exemplo `Authorization` / `Bearer ...`, ou `x-api-key` / valor da chave.
|
||||
Credenciais ficam somente na memória e precisam ser informadas novamente após
|
||||
recarregar. Não são gravadas em `sessionStorage` nem colocadas na URL.
|
||||
|
||||
Nenhuma operação de envio é repetida automaticamente. Se a resposta do envio
|
||||
não chegar, a tela informa a incerteza, pois o serviço pode ter aceitado a
|
||||
mensagem. O botão **Retomar** consulta a mesma Task, quando seu ID é conhecido.
|
||||
Trocar conexão, tenant, perfil ou backend requer reconectar e inicia outra
|
||||
conversa quando a identidade da configuração mudou.
|
||||
|
||||
## Limites herdados do serviço
|
||||
|
||||
O frontend não transforma o armazenamento em memória em armazenamento durável.
|
||||
Uma Task perdida ao reiniciar o serviço não pode ser recuperada pela tela.
|
||||
O cancelamento local não desfaz efeitos externos já confirmados e não garante
|
||||
cancelar uma operação já aceita por um agente remoto.
|
||||
|
||||
`auth-required` indica que a tarefa aguarda autorização, mas esta interface não
|
||||
implementa login OAuth nem aprovação externa automática. Realize o fluxo exigido
|
||||
pelo agente, atualize a credencial se necessário e retome a tarefa.
|
||||
|
||||
Não há upload de arquivos, push notifications, gRPC ou emissão de tokens do LLM.
|
||||
A seleção de versão 0.3 é explícita quando o serviço publica um Agent Card 1.0
|
||||
mas também aceita o formato 0.3, como ocorre no adaptador do framework.
|
||||
|
||||
## Testes
|
||||
|
||||
Sem bibliotecas JavaScript adicionais, com Node.js 22 ou superior:
|
||||
|
||||
```sh
|
||||
cd apps/agent_frontend_a2a
|
||||
node --test tests/client.test.js
|
||||
```
|
||||
|
||||
Com a demonstração em execução:
|
||||
|
||||
```sh
|
||||
A2A_DEMO_URL=http://localhost:8000 node --test tests/*.test.js
|
||||
```
|
||||
|
||||
Os testes de integração exercitam o cliente JavaScript contra o SDK real do
|
||||
framework: A2A 1.0 e 0.3, envio, streaming, consulta, confirmação, cancelamento
|
||||
e falha. Os testes de interface também foram realizados em navegador local.
|
||||
167
apps/agent_frontend_a2a/a2a-client.js
Normal file
167
apps/agent_frontend_a2a/a2a-client.js
Normal file
@@ -0,0 +1,167 @@
|
||||
export const uid = () => globalThis.crypto.randomUUID();
|
||||
export const stateName = (state = '') => state.replace(/^TASK_STATE_/, '').toLowerCase().replaceAll('_', '-');
|
||||
export const isStopped = state => ['completed', 'failed', 'canceled', 'rejected', 'input-required', 'auth-required'].includes(stateName(state));
|
||||
export const needsInput = state => ['input-required', 'auth-required'].includes(stateName(state));
|
||||
|
||||
export class RpcError extends Error {
|
||||
constructor(error) { super(error.message || 'O serviço retornou um erro.'); this.name = 'RpcError'; this.code = error.code; }
|
||||
}
|
||||
export function serviceURLs(value) {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Informe uma URL HTTP ou HTTPS sem credenciais, parâmetros ou fragmentos.');
|
||||
const base = url.href.replace(/\/$/, '').replace(/\/a2a$/, '');
|
||||
return { base, rpc: `${base}/a2a`, card: `${base}/.well-known/agent-card.json` };
|
||||
}
|
||||
export function normalizeResult(result) {
|
||||
if (!result || typeof result !== 'object') throw new Error('Resposta A2A inválida.');
|
||||
for (const [key, type] of [['task','task'], ['message','message'], ['statusUpdate','status'], ['artifactUpdate','artifact']]) {
|
||||
if (result[key]) return { type, value: result[key] };
|
||||
}
|
||||
const kinds = { task: 'task', message: 'message', 'status-update': 'status', 'artifact-update': 'artifact' };
|
||||
if (kinds[result.kind]) return { type: kinds[result.kind], value: result };
|
||||
throw new Error('O serviço não retornou uma mensagem, tarefa ou evento A2A reconhecido.');
|
||||
}
|
||||
export function selectVersion(card, preference = 'auto') {
|
||||
if (preference !== 'auto') return preference;
|
||||
const versions = (card.supportedInterfaces || []).filter(i => i.protocolBinding === 'JSONRPC').map(i => i.protocolVersion);
|
||||
if (versions.some(v => /^1\./.test(v))) return '1.0';
|
||||
if (/^0\.3/.test(card.protocolVersion || '') || versions.some(v => /^0\.3/.test(v))) return '0.3';
|
||||
throw new Error('O Agent Card não anuncia A2A 1.0 ou 0.3 por JSON-RPC.');
|
||||
}
|
||||
|
||||
// SSE over POST/fetch supports authentication headers and both A2A versions.
|
||||
export async function* parseSSE(body) {
|
||||
if (!body) throw new Error('O serviço não abriu o fluxo de eventos.');
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', data = [], size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const end = buffer.search(/[\r\n]/);
|
||||
if (end < 0 || (!done && buffer[end] === '\r' && end === buffer.length - 1)) break;
|
||||
const line = buffer.slice(0, end);
|
||||
buffer = buffer.slice(end + (buffer.slice(end, end + 2) === '\r\n' ? 2 : 1));
|
||||
if (!line) {
|
||||
if (data.length) yield data.join('\n');
|
||||
data = []; size = 0;
|
||||
} else if (line.startsWith('data:')) {
|
||||
const entry = line.slice(5).replace(/^ /, '');
|
||||
data.push(entry); size += entry.length;
|
||||
}
|
||||
if (size > 4 * 1024 * 1024) throw new Error('Evento recebido excedeu o limite de tamanho.');
|
||||
}
|
||||
if (buffer.length > 4 * 1024 * 1024) throw new Error('Evento recebido excedeu o limite de tamanho.');
|
||||
if (done) break; // A partial frame is not an event; the caller can recover with GetTask.
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export class A2AClient {
|
||||
constructor({ url, version = '1.0', headers = {}, fetchImpl = globalThis.fetch }) {
|
||||
this.urls = serviceURLs(url); this.version = version; this.fetch = fetchImpl.bind(globalThis);
|
||||
this.headers = { ...headers, 'A2A-Version': version };
|
||||
}
|
||||
method(v1, v03) { return this.version === '0.3' ? v03 : v1; }
|
||||
async discover(signal) {
|
||||
const response = await this.fetch(this.urls.card, { headers: this.headers, credentials: 'omit', signal });
|
||||
if (!response.ok) throw new Error(`Não foi possível carregar o Agent Card (HTTP ${response.status}).`);
|
||||
return response.json();
|
||||
}
|
||||
async rpc(method, params, { signal, timeoutMs = 15000 } = {}) {
|
||||
const id = uid();
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const response = await this.fetch(this.urls.rpc, {
|
||||
method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, credentials: 'omit',
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
if (!response.ok) throw new Error(`O serviço retornou HTTP ${response.status}. Confira o endereço e a autenticação.`);
|
||||
const data = await response.json();
|
||||
return this.validate(data, id);
|
||||
}
|
||||
validate(data, id) {
|
||||
if (data.jsonrpc !== '2.0' || data.id !== id) throw new Error('Resposta JSON-RPC inválida.');
|
||||
if (data.error) throw new RpcError(data.error);
|
||||
if (!Object.hasOwn(data, 'result')) throw new Error('Resposta sem resultado A2A.');
|
||||
return data.result;
|
||||
}
|
||||
async sendMessage(text, { contextId, taskId, tenantId, agentId, backendId, data = {}, signal } = {}) {
|
||||
const legacy = this.version === '0.3';
|
||||
const message = {
|
||||
messageId: uid(), role: legacy ? 'user' : 'ROLE_USER', contextId,
|
||||
parts: [{ text }, { data }], metadata: {},
|
||||
};
|
||||
if (taskId) message.taskId = taskId;
|
||||
for (const [key, value] of Object.entries({ tenant_id: tenantId, agent_id: agentId, backend_id: backendId })) {
|
||||
if (value) message.metadata[key] = value;
|
||||
}
|
||||
if (legacy) {
|
||||
message.kind = 'message'; message.parts[0].kind = 'text'; message.parts[1].kind = 'data';
|
||||
}
|
||||
const result = await this.rpc(this.method('SendMessage', 'message/send'), {
|
||||
message, configuration: legacy ? { blocking: false } : { returnImmediately: true },
|
||||
}, { signal });
|
||||
return normalizeResult(result);
|
||||
}
|
||||
async getTask(id, signal) { return this.rpc(this.method('GetTask', 'tasks/get'), { id }, { signal }); }
|
||||
async cancelTask(id) { return this.rpc(this.method('CancelTask', 'tasks/cancel'), { id }); }
|
||||
async *subscribe(id, signal) {
|
||||
const requestId = uid();
|
||||
const response = await this.fetch(this.urls.rpc, {
|
||||
method: 'POST', credentials: 'omit', signal,
|
||||
headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method: this.method('SubscribeToTask', 'tasks/resubscribe'), params: { id } }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Não foi possível acompanhar os eventos (HTTP ${response.status}).`);
|
||||
if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
|
||||
this.validate(await response.json(), requestId);
|
||||
throw new Error('O serviço não retornou um fluxo SSE.');
|
||||
}
|
||||
for await (const frame of parseSSE(response.body)) {
|
||||
yield normalizeResult(this.validate(JSON.parse(frame), requestId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskView {
|
||||
constructor(task = {}) { this.task = structuredClone(task); this.task.artifacts ||= []; }
|
||||
apply(event) {
|
||||
const value = event.value;
|
||||
if (event.type === 'task') {
|
||||
if (this.task.id && value.id !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
this.task = structuredClone(value); this.task.artifacts ||= [];
|
||||
}
|
||||
else if (event.type === 'status') {
|
||||
if (this.task.id && value.taskId !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
this.task.id = value.taskId; this.task.contextId = value.contextId; this.task.status = value.status;
|
||||
} else if (event.type === 'artifact') {
|
||||
if (this.task.id && value.taskId !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
const index = this.task.artifacts.findIndex(a => a.artifactId === value.artifact.artifactId);
|
||||
if (index < 0) this.task.artifacts.push(structuredClone(value.artifact));
|
||||
else if (value.append) this.task.artifacts[index].parts.push(...structuredClone(value.artifact.parts || []));
|
||||
else this.task.artifacts[index] = structuredClone(value.artifact);
|
||||
}
|
||||
return this.task;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayResponse(value, depth = 0) {
|
||||
if (!value || depth > 5) return null;
|
||||
const task = value.task || value;
|
||||
const parts = task.artifacts?.at(-1)?.parts || task.status?.message?.parts || value.message?.parts || value.parts || [];
|
||||
const native = parts.find(p => p.data && typeof p.data === 'object' && ('text' in p.data || 'speak' in p.data || 'metadata' in p.data))?.data;
|
||||
if (native) {
|
||||
const remote = native.metadata?.a2a_result;
|
||||
return (remote && displayResponse(remote, depth + 1)) || native;
|
||||
}
|
||||
const text = parts.filter(p => typeof p.text === 'string').map(p => p.text).join('');
|
||||
if (text) return { text };
|
||||
const structured = parts.filter(p => 'data' in p).map(p => p.data);
|
||||
return structured.length ? { text: JSON.stringify(structured.length === 1 ? structured[0] : structured, null, 2) } : null;
|
||||
}
|
||||
225
apps/agent_frontend_a2a/app.js
Normal file
225
apps/agent_frontend_a2a/app.js
Normal file
@@ -0,0 +1,225 @@
|
||||
import { A2AClient, TaskView, displayResponse, isStopped, needsInput, selectVersion, stateName, uid } from './a2a-client.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const value = id => $(id).value.trim();
|
||||
const labels = { submitted:'Tarefa recebida', working:'Agente trabalhando', completed:'Concluída', failed:'Falha no processamento', canceled:'Tarefa cancelada', rejected:'Solicitação recusada', 'input-required':'Aguardando sua resposta', 'auth-required':'Aguardando autorização' };
|
||||
const eventLabels = { 'flow.start':'Fluxo iniciado', 'session.upserted':'Sessão preparada', 'message.received':'Mensagem recebida', 'workflow.started':'Workflow iniciado', 'workflow.completed':'Workflow concluído', 'message.responded':'Resposta disponível', 'flow.end':'Fluxo finalizado', 'a2a.backend.status':'Status do backend atualizado' };
|
||||
const state = { client:null, card:null, contextId:uid(), view:new TaskView(), connected:false, busy:false, epoch:0, controller:null, turn:null, events:0, lastState:'', scope:null };
|
||||
const savedKey = 'framework-a2a-session-v1';
|
||||
const connectionFields = ['serviceUrl','target','version','skill','tenant','tracking','customerKey','contractKey','agentOverride','authHeader','authValue'];
|
||||
const messageGroups = new Map();
|
||||
|
||||
function notice(text = '') { $('notice').textContent = text; $('notice').hidden = !text; }
|
||||
function setStatus(text, kind = '') { $('taskStatus').textContent = text; $('taskDot').className = `dot ${kind}`; }
|
||||
function setBusy(busy) { state.busy = busy; document.body.setAttribute('aria-busy', String(busy)); controls(); }
|
||||
function controls() {
|
||||
const active = state.view.task.id && !isStopped(state.view.task.status?.state);
|
||||
const allowSend = state.connected && !state.busy && !active;
|
||||
$('message').disabled = !allowSend; $('sendButton').disabled = !allowSend;
|
||||
$('cancelButton').disabled = !state.connected || !(active || needsInput(state.view.task.status?.state));
|
||||
$('resumeButton').disabled = !state.connected || state.busy || !state.view.task.id;
|
||||
$('newConversation').disabled = state.busy;
|
||||
$('connectButton').disabled = state.busy;
|
||||
for (const id of connectionFields) $(id).disabled = state.busy;
|
||||
}
|
||||
function log(text) {
|
||||
const item = document.createElement('li');
|
||||
const time = document.createElement('time'); time.textContent = new Date().toLocaleTimeString('pt-BR');
|
||||
item.append(time, document.createTextNode(text)); $('activity').append(item);
|
||||
while ($('activity').children.length > 80) $('activity').firstChild.remove();
|
||||
$('eventCount').textContent = `${++state.events} eventos`;
|
||||
}
|
||||
function saveSession() {
|
||||
// Only identifiers and connection settings: no credentials or message content.
|
||||
try { sessionStorage.setItem(savedKey, JSON.stringify({
|
||||
serviceUrl: state.client?.urls.base, version:state.client?.version, target:value('target'),
|
||||
contextId:state.contextId, taskId:state.view.task.id, taskState:state.view.task.status?.state,
|
||||
tenant:value('tenant'), skill:value('skill'), agentOverride:value('agentOverride'),
|
||||
})); } catch { /* Storage can be unavailable in private browsing. */ }
|
||||
}
|
||||
function ids() {
|
||||
$('taskId').textContent = state.view.task.id || '—'; $('taskShort').textContent = state.view.task.id ? `#${state.view.task.id.slice(0,8)}` : '';
|
||||
$('contextId').textContent = state.contextId; $('contextShort').textContent = `Conversa ${state.contextId.slice(0,8)}`;
|
||||
}
|
||||
function stopWatcher() { ++state.epoch; state.controller?.abort(); state.controller = null; }
|
||||
function newConversation() {
|
||||
stopWatcher(); state.contextId = uid(); state.view = new TaskView(); state.turn = null; state.lastState = ''; messageGroups.clear();
|
||||
$('chat').replaceChildren(); $('activity').replaceChildren(); state.events = 0; $('eventCount').textContent='0 eventos'; notice();
|
||||
setBusy(false); ids(); setStatus(state.connected ? 'Pronto para conversar' : 'Pronto para conectar'); saveSession(); $('message').focus();
|
||||
}
|
||||
function userMessage(text) {
|
||||
const div = document.createElement('div'); div.className='message user'; div.textContent=text;
|
||||
$('welcome')?.remove(); $('chat').append(div); scrollChat();
|
||||
}
|
||||
function agentMessages(response) {
|
||||
const metadata = response.metadata || {};
|
||||
const candidates = response.supervisor_results || metadata.supervisor_results || response.agent_responses || metadata.agent_responses;
|
||||
if (Array.isArray(candidates)) {
|
||||
const parts = candidates.map(p => ({ agent:p.agent || p.agent_id || p.name || 'Agente', text:p.answer ?? p.text ?? p.message ?? p.response ?? p.content, primary:p.primary }));
|
||||
const valid = parts.filter(p => p.text != null && String(p.text).trim());
|
||||
if (valid.length) return valid.sort((a,b)=>Number(Boolean(a.primary))-Number(Boolean(b.primary)));
|
||||
}
|
||||
const text = response.text ?? response.speak ?? response.content ?? '';
|
||||
return [{ agent:metadata.active_agent || metadata.agent_id || state.card?.name || 'Agente', text }];
|
||||
}
|
||||
function scrollChat() { $('chat').scrollTop = $('chat').scrollHeight; }
|
||||
function renderResponse(response) {
|
||||
if (!response) return;
|
||||
$('welcome')?.remove(); state.turn ||= uid();
|
||||
let group = messageGroups.get(state.turn);
|
||||
if (!group) { group=document.createElement('section'); group.className='message agent'; group.setAttribute('aria-label','Resposta do agente'); messageGroups.set(state.turn,group); $('chat').append(group); }
|
||||
const signature=JSON.stringify(response); if (group.dataset.signature === signature) return;
|
||||
group.dataset.signature=signature; group.replaceChildren();
|
||||
for (const part of agentMessages(response)) {
|
||||
const section=document.createElement('section'); section.className='agent-section';
|
||||
const header=document.createElement('div'); header.className='agent-header';
|
||||
const avatar=document.createElement('span'); avatar.className='agent-avatar'; avatar.textContent='O'; avatar.setAttribute('aria-hidden','true');
|
||||
const name=document.createElement('span'); name.textContent=String(part.agent).replaceAll('_',' ');
|
||||
const body=document.createElement('div'); body.className='agent-body'; body.textContent=typeof part.text==='string' ? part.text : JSON.stringify(part.text,null,2);
|
||||
header.append(avatar,name); section.append(header,body); group.append(section);
|
||||
}
|
||||
scrollChat();
|
||||
}
|
||||
function updateTask(event) {
|
||||
const task=state.view.apply(event);
|
||||
if (task.contextId) state.contextId=task.contextId;
|
||||
const current=stateName(task.status?.state);
|
||||
if (current && current!==state.lastState) { log(labels[current] || current); state.lastState=current; }
|
||||
if (event.type==='status' && event.value.metadata?.framework_event) log(eventLabels[event.value.metadata.framework_event] || event.value.metadata.framework_event);
|
||||
setStatus(labels[current] || 'Acompanhando tarefa', ['failed','rejected'].includes(current) ? 'error' : isStopped(current) ? '' : 'busy');
|
||||
if (event.type==='artifact' || isStopped(current)) renderResponse(displayResponse(task));
|
||||
ids(); saveSession(); controls();
|
||||
}
|
||||
function sleep(ms,signal) {
|
||||
return new Promise((resolve,reject)=>{
|
||||
if (signal.aborted) { reject(new DOMException('Interrompido','AbortError')); return; }
|
||||
const abort=()=>{clearTimeout(timer);reject(new DOMException('Interrompido','AbortError'));};
|
||||
const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},ms);
|
||||
signal.addEventListener('abort',abort,{once:true});
|
||||
});
|
||||
}
|
||||
async function watchTask() {
|
||||
stopWatcher(); const epoch=state.epoch; const controller=new AbortController(); state.controller=controller;
|
||||
const signal=controller.signal; const id=state.view.task.id; setBusy(true);
|
||||
try {
|
||||
if (value('tracking')==='stream' && state.card?.capabilities?.streaming && !isStopped(state.view.task.status?.state)) {
|
||||
try {
|
||||
log('Conectando ao fluxo da tarefa');
|
||||
for await (const event of state.client.subscribe(id,signal)) {
|
||||
if (epoch!==state.epoch) return; updateTask(event);
|
||||
if (isStopped(state.view.task.status?.state)) break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
log('Fluxo interrompido; recuperando por consulta');
|
||||
}
|
||||
}
|
||||
// Always obtain the authoritative final snapshot; also recovers a fast Task
|
||||
// that completed before SubscribeToTask or a dropped/truncated SSE stream.
|
||||
let failures=0;
|
||||
while (true) {
|
||||
try {
|
||||
const task=await state.client.getTask(id,signal);
|
||||
if (epoch!==state.epoch) return;
|
||||
updateTask({type:'task',value:task}); failures=0;
|
||||
if (isStopped(task.status?.state)) break;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
if (++failures>=3 || error.code) throw error;
|
||||
log('Conexão indisponível; tentando consultar novamente');
|
||||
}
|
||||
await sleep(failures ? failures*1000 : 900,signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted && epoch===state.epoch) {
|
||||
notice(`Acompanhamento pausado: ${error.message} Use Retomar para consultar a mesma tarefa. Nenhuma mensagem foi reenviada.`);
|
||||
setStatus('Acompanhamento pausado','error'); log('Acompanhamento pausado');
|
||||
}
|
||||
} finally {
|
||||
if (epoch===state.epoch) { setBusy(false); $('message').focus(); }
|
||||
}
|
||||
}
|
||||
function authHeaders() {
|
||||
const secret=$('authValue').value;
|
||||
if (!secret) return {};
|
||||
const name=value('authHeader');
|
||||
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\r\n]/.test(secret)) throw new Error('Cabeçalho de autenticação inválido.');
|
||||
if (['content-type','accept','a2a-version','host','origin','cookie'].includes(name.toLowerCase())) throw new Error('Use Authorization ou o cabeçalho de autenticação configurado no serviço.');
|
||||
return {[name]:secret};
|
||||
}
|
||||
$('connectionForm').addEventListener('submit',async event=>{
|
||||
event.preventDefault(); notice(); setBusy(true); setStatus('Conectando…','busy');
|
||||
try {
|
||||
const candidate=new A2AClient({url:value('serviceUrl'),version:value('version')==='0.3'?'0.3':'1.0',headers:authHeaders()});
|
||||
const card=await candidate.discover(AbortSignal.timeout(12000)); candidate.version=selectVersion(card,value('version')); candidate.headers['A2A-Version']=candidate.version;
|
||||
const scope=JSON.stringify([candidate.urls.base,candidate.version,value('target'),value('tenant'),value('skill'),value('agentOverride')]);
|
||||
if (state.scope && scope!==state.scope) newConversation();
|
||||
state.scope=scope; state.client=candidate; state.card=card; state.connected=true;
|
||||
const previous=value('skill'); $('skill').replaceChildren(new Option('Seleção automática',''));
|
||||
for (const skill of card.skills || []) $('skill').append(new Option(skill.name || skill.id,skill.id));
|
||||
$('skill').value=[...$('skill').options].some(o=>o.value===previous)?previous:'';
|
||||
$('serviceName').textContent=card.name || 'Serviço A2A'; $('connectionDetail').textContent=`${(card.skills || []).length === 1 ? '1 opção disponível' : `${(card.skills || []).length} opções disponíveis`} · ${card.capabilities?.streaming ? 'Atualizações em tempo real' : 'Consulta de tarefas'}`;
|
||||
$('connectionDot').className='dot'; $('versionBadge').textContent=`A2A ${candidate.version}`;
|
||||
setStatus(state.view.task.id?'Conectado · tarefa disponível para consulta':'Pronto para conversar');
|
||||
log('Conectado ao serviço'); ids(); saveSession();
|
||||
} catch(error) {
|
||||
state.connected=false; $('connectionDot').className='dot error'; $('connectionDetail').textContent='Não foi possível conectar.';
|
||||
setStatus('Falha na conexão','error'); notice(`${error.message} Se o navegador bloquear a conexão, confira CORS_ORIGINS no serviço e use HTTP/HTTPS para servir esta página.`);
|
||||
} finally { setBusy(false); }
|
||||
});
|
||||
$('messageForm').addEventListener('submit',async event=>{
|
||||
event.preventDefault(); const text=value('message'); if(!text || !state.connected || $('sendButton').disabled) return;
|
||||
notice(); const previousTask=state.view.task; const resumeId=needsInput(previousTask.status?.state)?previousTask.id:undefined;
|
||||
state.turn=uid(); userMessage(text); $('message').value=''; $('message').style.height='auto'; setBusy(true); setStatus('Enviando mensagem…','busy');
|
||||
const messageId=uid();
|
||||
const business_context={customer_key:value('customerKey')||null,contract_key:value('contractKey')||null,interaction_key:messageId,session_key:state.contextId};
|
||||
const data={message_id:messageId,business_context,context:{business_context}};
|
||||
if(value('customerKey')) data.customer_key=value('customerKey');
|
||||
if(value('contractKey')) data.contract_key=value('contractKey');
|
||||
try {
|
||||
const gateway=value('target')==='gateway';
|
||||
const result=await state.client.sendMessage(text,{contextId:state.contextId,taskId:resumeId,tenantId:value('tenant')||'default',
|
||||
agentId:gateway?value('agentOverride'):value('skill'),backendId:gateway?value('skill'):undefined,data});
|
||||
if(result.type==='message') {
|
||||
state.view=new TaskView(); if(result.value.contextId) state.contextId=result.value.contextId;
|
||||
renderResponse(displayResponse(result.value)); setStatus('Resposta recebida'); ids(); saveSession(); setBusy(false);
|
||||
} else if(result.type==='task') {
|
||||
state.view=new TaskView(); state.lastState=''; updateTask(result); log(`Tarefa ${state.view.task.id.slice(0,8)} recebida`); await watchTask();
|
||||
} else throw new Error('Resposta de envio inesperada.');
|
||||
} catch(error) {
|
||||
notice(`Não foi possível confirmar o envio: ${error.message} Ele pode ter sido aceito pelo serviço; não houve reenvio automático.`);
|
||||
setStatus('Envio não confirmado','error'); setBusy(false);
|
||||
}
|
||||
});
|
||||
$('resumeButton').addEventListener('click',()=>{notice();state.turn ||= uid();watchTask();});
|
||||
$('cancelButton').addEventListener('click',async()=>{
|
||||
const id=state.view.task.id; if(!id || !state.connected) return;
|
||||
stopWatcher(); setBusy(true); setStatus('Solicitando cancelamento…','busy');
|
||||
try { updateTask({type:'task',value:await state.client.cancelTask(id)});log('Cancelamento confirmado pelo serviço');notice(); }
|
||||
catch(error) {
|
||||
try {updateTask({type:'task',value:await state.client.getTask(id)});} catch { /* Keep the last known Task. */ }
|
||||
notice(`O serviço não confirmou o cancelamento: ${error.message} Consulte a tarefa com Retomar.`);
|
||||
} finally {setBusy(false);}
|
||||
});
|
||||
$('newConversation').addEventListener('click',newConversation);
|
||||
$('message').addEventListener('keydown',event=>{if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();$('messageForm').requestSubmit();}});
|
||||
$('message').addEventListener('input',()=>{ $('message').style.height='auto';$('message').style.height=`${Math.min($('message').scrollHeight,150)}px`; });
|
||||
for(const button of document.querySelectorAll('[data-prompt]')) button.addEventListener('click',()=>{if(!$('message').disabled){$('message').value=button.dataset.prompt;$('message').focus();}else notice('Conecte ao serviço para iniciar a conversa.');});
|
||||
$('target').addEventListener('change',()=>{ $('skillLabel').firstChild.textContent=value('target')==='gateway'?'Backend no Gateway':'Agente'; });
|
||||
for(const id of ['serviceUrl','version','target','tenant','skill','agentOverride','authHeader','authValue']) $(id).addEventListener('change',()=>{
|
||||
if(state.connected){state.connected=false;$('connectionDot').className='dot muted';setStatus('Configuração alterada · conecte novamente');controls();}
|
||||
});
|
||||
try {
|
||||
const saved=JSON.parse(sessionStorage.getItem(savedKey)||'null');
|
||||
if(saved?.serviceUrl){
|
||||
$('serviceUrl').value=saved.serviceUrl;$('version').value=saved.version||'auto';$('target').value=saved.target||'backend';$('tenant').value=saved.tenant||'default';$('agentOverride').value=saved.agentOverride||'';
|
||||
if(saved.skill)$('skill').append(new Option(saved.skill,saved.skill));$('skill').value=saved.skill||'';
|
||||
state.contextId=saved.contextId||uid();state.view=new TaskView(saved.taskId?{id:saved.taskId,contextId:state.contextId,status:{state:saved.taskState}}:{});
|
||||
state.scope=JSON.stringify([saved.serviceUrl,saved.version,saved.target||'backend',saved.tenant||'default',saved.skill||'',saved.agentOverride||'']);
|
||||
if(saved.taskId)notice('Uma tarefa desta aba está disponível. Conecte ao mesmo serviço e clique em Retomar para consultar o resultado.');
|
||||
}
|
||||
} catch { /* An unavailable or invalid saved session starts a fresh conversation. */ }
|
||||
const requestedService=new URLSearchParams(location.search).get('service');
|
||||
if(requestedService)$('serviceUrl').value=requestedService;
|
||||
else if(!state.scope && location.port==='8000')$('serviceUrl').value=location.origin;
|
||||
$('target').dispatchEvent(new Event('change'));ids();controls();
|
||||
52
apps/agent_frontend_a2a/index.html
Normal file
52
apps/agent_frontend_a2a/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Oracle Agent Workspace · A2A</title><link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="workspace">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="./"><img class="oracle-logo" src="oracle-logo.svg" alt="Oracle"><span class="brand-copy">Agent Workspace<small>FRAMEWORK OCI · A2A</small></span></a>
|
||||
<div class="sidebar-heading"><span>CONEXÃO</span><span class="badge" id="versionBadge">A2A</span></div>
|
||||
<form id="connectionForm" class="connection-form">
|
||||
<label>Endereço do serviço<input id="serviceUrl" type="url" value="http://localhost:8000" required spellcheck="false"></label>
|
||||
<div class="two-columns">
|
||||
<label>Destino<select id="target" aria-label="Destino"><option value="backend">Agente</option><option value="gateway">Gateway</option></select></label>
|
||||
<label>Versão<select id="version" aria-label="Versão"><option value="auto">Automática</option><option value="1.0">A2A 1.0</option><option value="0.3">A2A 0.3</option></select></label>
|
||||
</div>
|
||||
<button id="connectButton" class="primary" type="submit">Conectar ao serviço <span aria-hidden="true">↗</span></button>
|
||||
</form>
|
||||
<div class="service-card"><span id="connectionDot" class="dot"></span><div><strong id="serviceName">Nenhum serviço conectado</strong><p id="connectionDetail">Conecte para carregar os agentes disponíveis.</p></div></div>
|
||||
<div class="sidebar-heading"><span>CONVERSA</span></div>
|
||||
<label id="skillLabel">Agente<select id="skill" aria-label="Agente ou backend"><option value="">Seleção automática</option></select></label>
|
||||
<label>Tenant<input id="tenant" value="default" spellcheck="false"></label>
|
||||
<label>Acompanhamento<select id="tracking" aria-label="Acompanhamento"><option value="stream">Tempo real</option><option value="poll">Consulta periódica</option></select></label>
|
||||
<details class="advanced"><summary>Contexto e autenticação</summary>
|
||||
<label>Identificador do cliente<input id="customerKey" placeholder="customer_key" autocomplete="off"></label>
|
||||
<label>Contrato ou pedido<input id="contractKey" placeholder="contract_key" autocomplete="off"></label>
|
||||
<label>Perfil do agente no backend<input id="agentOverride" placeholder="Opcional; usado pelo Gateway" autocomplete="off"></label>
|
||||
<label>Cabeçalho de autenticação<input id="authHeader" value="Authorization" autocomplete="off" spellcheck="false"></label>
|
||||
<label>Valor do cabeçalho<input id="authValue" type="password" placeholder="Bearer … ou credencial configurada" autocomplete="off"></label>
|
||||
<p class="hint">A credencial fica apenas na memória desta página.</p>
|
||||
</details>
|
||||
<div class="sidebar-footer"><span class="dot muted"></span> Protocolo A2A · JSON-RPC + SSE</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar"><div><span class="eyebrow">SEU ESPAÇO DE TRABALHO</span><h1>Converse com seus agentes</h1></div><button id="newConversation" class="secondary" type="button">+ Nova conversa</button></header>
|
||||
<section class="task-strip" aria-label="Acompanhamento da tarefa">
|
||||
<div class="status-pill"><span id="taskDot" class="dot muted"></span><span id="taskStatus" role="status" aria-live="polite">Pronto para conectar</span></div>
|
||||
<span id="taskShort" class="task-id"></span>
|
||||
<div class="task-actions"><button id="resumeButton" class="text-button" type="button" disabled>Retomar</button><button id="cancelButton" class="text-button danger" type="button" disabled>Cancelar tarefa</button></div>
|
||||
</section>
|
||||
<div id="notice" class="notice" role="alert" hidden></div>
|
||||
<section id="chat" class="chat" role="log" aria-label="Conversa com o agente" aria-live="polite">
|
||||
<div id="welcome" class="welcome"><span class="eyebrow">CONVERSAS QUE VIRAM AÇÕES</span><h2>O próximo passo<br>começa com uma mensagem.</h2><p>Conecte um agente ou o Gateway do framework.<br>Acompanhe cada tarefa até a resposta.</p><div class="suggestions"><button type="button" data-prompt="O que você pode fazer por mim?">Conhecer o agente <span>↗</span></button><button type="button" data-prompt="Quero consultar meu pedido.">Consultar um pedido <span>↗</span></button></div></div>
|
||||
</section>
|
||||
<div class="composer-area"><form id="messageForm" class="composer"><label class="sr-only" for="message">Mensagem para o agente</label><textarea id="message" rows="1" placeholder="Escreva uma mensagem para começar…" disabled></textarea><button id="sendButton" type="submit" class="send-button" aria-label="Enviar mensagem" disabled>↑</button></form><div class="composer-footer"><span>Enter envia · Shift + Enter quebra a linha</span><span id="contextShort">Conversa nova</span></div></div>
|
||||
<details class="activity-panel"><summary>Atividade da tarefa <span id="eventCount">0 eventos</span></summary><ol id="activity"></ol><dl><dt>Contexto</dt><dd id="contextId">—</dd><dt>Tarefa</dt><dd id="taskId">—</dd></dl></details>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2
apps/agent_frontend_a2a/oracle-logo.svg
Normal file
2
apps/agent_frontend_a2a/oracle-logo.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#EA1B22" fill-rule="evenodd" d="M.1 8c0 2.761 2.237 5 4.997 5h5.806A4.999 4.999 0 0015.9 8c0-2.761-2.237-5-4.997-5H5.097A4.999 4.999 0 00.1 8zm13.911 0a3.235 3.235 0 01-3.234 3.237h-5.55A3.235 3.235 0 011.991 8a3.235 3.235 0 013.234-3.236h5.551A3.235 3.235 0 0114.011 8z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 531 B |
1
apps/agent_frontend_a2a/package.json
Normal file
1
apps/agent_frontend_a2a/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"agent-framework-a2a-frontend","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
|
||||
42
apps/agent_frontend_a2a/styles.css
Normal file
42
apps/agent_frontend_a2a/styles.css
Normal file
File diff suppressed because one or more lines are too long
86
apps/agent_frontend_a2a/tests/client.test.js
Normal file
86
apps/agent_frontend_a2a/tests/client.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { A2AClient, TaskView, displayResponse, parseSSE, serviceURLs, selectVersion, normalizeResult, isStopped, needsInput, RpcError } from '../a2a-client.js';
|
||||
|
||||
function bodyFor(text, size = 1) {
|
||||
const bytes=new TextEncoder().encode(text);
|
||||
return new ReadableStream({start(controller){for(let i=0;i<bytes.length;i+=size)controller.enqueue(bytes.slice(i,i+size));controller.close();}});
|
||||
}
|
||||
|
||||
test('SSE handles fragmented UTF-8, CRLF, comments and multi-line data',async()=>{
|
||||
const text=': ping\r\nid: 1\r\nevent: message\r\ndata: {"text":\r\ndata: "Olá 👋"}\r\n\r\ndata: second\n\n';
|
||||
const frames=[];for await(const frame of parseSSE(bodyFor(text)))frames.push(frame);
|
||||
assert.deepEqual(frames,['{"text":\n"Olá 👋"}','second']);
|
||||
});
|
||||
test('SSE discards an unfinished frame for recovery by GetTask',async()=>{
|
||||
const frames=[];for await(const frame of parseSSE(bodyFor('data: complete\n\ndata: truncated')))frames.push(frame);
|
||||
assert.deepEqual(frames,['complete']);
|
||||
});
|
||||
test('service URLs accept a base or /a2a and reject embedded credentials',()=>{
|
||||
assert.equal(serviceURLs('http://localhost:8000/a2a/').rpc,'http://localhost:8000/a2a');
|
||||
assert.equal(serviceURLs('https://host/prefix').card,'https://host/prefix/.well-known/agent-card.json');
|
||||
for(const url of ['javascript:alert(1)','https://user:secret@host','https://host?token=secret'])assert.throws(()=>serviceURLs(url));
|
||||
});
|
||||
test('version selection uses JSONRPC interfaces and allows explicit legacy compatibility',()=>{
|
||||
assert.equal(selectVersion({supportedInterfaces:[{protocolBinding:'JSONRPC',protocolVersion:'1.0'}]}),'1.0');
|
||||
assert.equal(selectVersion({protocolVersion:'0.3.0'}),'0.3');
|
||||
assert.equal(selectVersion({},'0.3'),'0.3');
|
||||
assert.throws(()=>selectVersion({supportedInterfaces:[{protocolBinding:'GRPC',protocolVersion:'1.0'}]}));
|
||||
});
|
||||
test('both versions normalize task, message and streaming events',()=>{
|
||||
assert.equal(normalizeResult({statusUpdate:{taskId:'t'}}).type,'status');
|
||||
assert.equal(normalizeResult({kind:'artifact-update',taskId:'t'}).type,'artifact');
|
||||
assert.equal(normalizeResult({message:{messageId:'m'}}).type,'message');
|
||||
assert.throws(()=>normalizeResult({unexpected:true}));
|
||||
});
|
||||
test('TaskView updates status, replaces snapshots and appends artifact chunks',()=>{
|
||||
const view=new TaskView({id:'t',artifacts:[]});
|
||||
view.apply({type:'artifact',value:{taskId:'t',artifact:{artifactId:'a',parts:[{text:'Hel'}]}}});
|
||||
view.apply({type:'artifact',value:{taskId:'t',append:true,artifact:{artifactId:'a',parts:[{text:'lo'}]}}});
|
||||
assert.equal(displayResponse(view.task).text,'Hello');
|
||||
view.apply({type:'status',value:{taskId:'t',contextId:'c',status:{state:'TASK_STATE_COMPLETED'}}});
|
||||
assert.ok(isStopped(view.task.status.state));assert.ok(needsInput('input-required'));
|
||||
assert.throws(()=>view.apply({type:'status',value:{taskId:'other',status:{state:'working'}}}));
|
||||
});
|
||||
test('framework text/data duplicates render once and native agent metadata survives',()=>{
|
||||
const native={text:'answer',metadata:{agent_responses:[{agent:'billing',text:'answer'}]}};
|
||||
const task={artifacts:[{parts:[{text:'answer'},{data:native}]}],status:{message:{parts:[{text:'answer'}]}}};
|
||||
assert.deepEqual(displayResponse(task),native);
|
||||
const wrapped={parts:[{data:{text:'answer',metadata:{a2a_result:{task}}}}]};
|
||||
assert.deepEqual(displayResponse(wrapped),native);
|
||||
});
|
||||
for(const legacy of [false,true]){
|
||||
test(`send/get/cancel use correct envelopes and credentials (${legacy?'0.3':'1.0'})`,async()=>{
|
||||
const requests=[];
|
||||
const fetchImpl=async function(url,options){
|
||||
assert.equal(this,globalThis);
|
||||
const body=JSON.parse(options.body);requests.push({url,options,body});
|
||||
return Response.json({jsonrpc:'2.0',id:body.id,result:body.method.includes('send')||body.method==='SendMessage' ? (legacy?{kind:'task',id:'t',status:{state:'working'}}:{task:{id:'t',status:{state:'TASK_STATE_WORKING'}}}):{id:'t'}});
|
||||
};
|
||||
const client=new A2AClient({url:'https://service',version:legacy?'0.3':'1.0',headers:{Authorization:'Bearer test'},fetchImpl});
|
||||
await client.sendMessage('hello',{contextId:'c',agentId:'a',backendId:'billing',tenantId:'tenant',data:{business_context:{customer_key:'customer'}}});
|
||||
await client.getTask('t');await client.cancelTask('t');
|
||||
assert.deepEqual(requests.map(r=>r.body.method),legacy?['message/send','tasks/get','tasks/cancel']:['SendMessage','GetTask','CancelTask']);
|
||||
const params=requests[0].body.params;
|
||||
assert.equal(params.message.role,legacy?'user':'ROLE_USER');assert.equal(params.message.metadata.backend_id,'billing');
|
||||
assert.equal(params.message.metadata.agent_id,'a');assert.equal(params.message.contextId,'c');
|
||||
assert.equal(legacy?params.configuration.blocking:params.configuration.returnImmediately,!legacy);
|
||||
assert.equal(requests[0].options.headers.Authorization,'Bearer test');
|
||||
assert.equal(requests[0].options.credentials,'omit');
|
||||
});
|
||||
}
|
||||
test('JSON-RPC errors and mismatched response IDs never become successful responses',async()=>{
|
||||
const bad=new A2AClient({url:'https://service',fetchImpl:async()=>Response.json({jsonrpc:'2.0',id:'wrong',result:{}})});
|
||||
await assert.rejects(bad.getTask('t'),/JSON-RPC/);
|
||||
const failure=new A2AClient({url:'https://service',fetchImpl:async(url,o)=>Response.json({jsonrpc:'2.0',id:JSON.parse(o.body).id,error:{code:-32001,message:'Task missing'}})});
|
||||
await assert.rejects(failure.getTask('t'),RpcError);
|
||||
});
|
||||
test('POST SSE supports auth headers, validates envelopes and streams canonical events',async()=>{
|
||||
const client=new A2AClient({url:'https://service',headers:{'x-api-key':'test'},fetchImpl:async(url,o)=>{
|
||||
assert.equal(o.method,'POST');assert.equal(o.headers['x-api-key'],'test');
|
||||
const body=JSON.parse(o.body);assert.equal(body.method,'SubscribeToTask');
|
||||
return new Response(bodyFor(`data: ${JSON.stringify({jsonrpc:'2.0',id:body.id,result:{statusUpdate:{taskId:'t',status:{state:'TASK_STATE_COMPLETED'}}}})}\n\n`),{headers:{'content-type':'text/event-stream'}});
|
||||
}});
|
||||
const events=[];for await(const event of client.subscribe('t'))events.push(event);
|
||||
assert.equal(events[0].type,'status');assert.equal(events[0].value.taskId,'t');
|
||||
});
|
||||
32
apps/agent_frontend_a2a/tests/integration.test.js
Normal file
32
apps/agent_frontend_a2a/tests/integration.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Run against examples/a2a_frontend_demo.py with A2A_DEMO_URL=http://127.0.0.1:8765.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { A2AClient, TaskView, displayResponse, isStopped, stateName } from '../a2a-client.js';
|
||||
const url=process.env.A2A_DEMO_URL;
|
||||
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function complete(client,task){
|
||||
const deadline=Date.now()+6000;
|
||||
while(!isStopped(task.status?.state)&&Date.now()<deadline){await delay(50);task=await client.getTask(task.id);}
|
||||
assert.ok(isStopped(task.status?.state));return task;
|
||||
}
|
||||
for(const version of ['1.0','0.3']){
|
||||
test(`SDK integration: submit, subscribe, poll and resume (${version})`,{skip:!url},async()=>{
|
||||
const client=new A2AClient({url,version});const card=await client.discover();assert.ok(card.capabilities.streaming);
|
||||
const first=await client.sendMessage('hello',{contextId:crypto.randomUUID()});assert.equal(first.type,'task');assert.ok(!isStopped(first.value.status.state));
|
||||
const view=new TaskView(first.value);let count=0;
|
||||
for await(const event of client.subscribe(first.value.id)){view.apply(event);count++;}
|
||||
assert.ok(count>0);const final=await client.getTask(first.value.id);
|
||||
assert.equal(stateName(final.status.state),'completed');assert.match(displayResponse(final).text,/hello/);
|
||||
const prompt=await client.sendMessage('/confirmar',{contextId:crypto.randomUUID()});
|
||||
const waiting=await complete(client,prompt.value);assert.equal(stateName(waiting.status.state),'input-required');
|
||||
const resumed=await client.sendMessage('sim',{contextId:waiting.contextId,taskId:waiting.id});
|
||||
const finished=await complete(client,resumed.value);assert.equal(finished.id,waiting.id);assert.equal(stateName(finished.status.state),'completed');
|
||||
});
|
||||
}
|
||||
test('SDK integration: cancellation and failed tasks',{skip:!url},async()=>{
|
||||
const client=new A2AClient({url});
|
||||
const slow=await client.sendMessage('/lento',{contextId:crypto.randomUUID()});
|
||||
await delay(50);const canceled=await client.cancelTask(slow.value.id);assert.equal(stateName(canceled.status.state),'canceled');
|
||||
const failed=await client.sendMessage('/erro',{contextId:crypto.randomUUID()});
|
||||
assert.equal(stateName((await complete(client,failed.value)).status.state),'failed');
|
||||
});
|
||||
35
apps/agent_gateway/.env.example
Normal file
35
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
apps/agent_gateway/Dockerfile
Normal file
6
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"]
|
||||
7
apps/agent_gateway/Dockerfile.a2a
Normal file
7
apps/agent_gateway/Dockerfile.a2a
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY libs/agent_framework /agent_framework
|
||||
COPY apps/agent_gateway /app
|
||||
RUN pip install --no-cache-dir -e '/agent_framework[a2a]' -r requirements.txt
|
||||
ENV ENABLE_A2A=true
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
83
apps/agent_gateway/README.md
Normal file
83
apps/agent_gateway/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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
|
||||
|
||||
## A2A opcional
|
||||
|
||||
A fachada A2A 1.0, a compatibilidade 0.3 e o catálogo misto de backends são
|
||||
explicados em [Integração A2A](../../docs/A2A_IMPLEMENTACAO.md).
|
||||
A ativação é opcional (`ENABLE_A2A=false` por padrão).
|
||||
|
||||
A fachada também oferece [Tasks A2A assíncronas e streaming](../../docs/A2A_ASSINCRONO.md),
|
||||
com envio imediato, consulta, reconexão e cancelamento. O template de backend
|
||||
pode publicar o mesmo protocolo diretamente.
|
||||
14
apps/agent_gateway/app/config/governance_loader.py
Normal file
14
apps/agent_gateway/app/config/governance_loader.py
Normal 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 {}
|
||||
1
apps/agent_gateway/app/governance/__init__.py
Normal file
1
apps/agent_gateway/app/governance/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
apps/agent_gateway/app/governance/audit.py
Normal file
14
apps/agent_gateway/app/governance/audit.py
Normal 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))
|
||||
11
apps/agent_gateway/app/governance/evaluation_hooks.py
Normal file
11
apps/agent_gateway/app/governance/evaluation_hooks.py
Normal 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
|
||||
66
apps/agent_gateway/app/governance/model_policies.py
Normal file
66
apps/agent_gateway/app/governance/model_policies.py
Normal 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}")
|
||||
35
apps/agent_gateway/app/governance/rate_limit.py
Normal file
35
apps/agent_gateway/app/governance/rate_limit.py
Normal 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)
|
||||
14
apps/agent_gateway/app/governance/usage.py
Normal file
14
apps/agent_gateway/app/governance/usage.py
Normal 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
|
||||
78
apps/agent_gateway/app/governance_middleware.py
Normal file
78
apps/agent_gateway/app/governance_middleware.py
Normal 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
|
||||
348
apps/agent_gateway/app/main.py
Normal file
348
apps/agent_gateway/app/main.py
Normal file
@@ -0,0 +1,348 @@
|
||||
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 app.supervisor_llm import build_supervisor_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 = build_supervisor_llm(settings) if settings.GLOBAL_ROUTING_MODE in {"supervisor", "hybrid"} else None
|
||||
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):
|
||||
|
||||
existing = await session_store.get(session_id)
|
||||
if existing and existing.active_backend and registry.get(existing.active_backend).protocol == "a2a":
|
||||
raise HTTPException(status_code=409, detail="A2A backend: use message/send; native event proxy is unavailable")
|
||||
|
||||
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)
|
||||
if backend.protocol == "a2a":
|
||||
yield 'event: error\ndata: {"message":"Native event proxy is unavailable for A2A backends"}\n\n'
|
||||
return
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
# Opt-in: existing deployments do not import or require the A2A SDK.
|
||||
if settings.ENABLE_A2A:
|
||||
from agent_framework.a2a.server import install_a2a
|
||||
|
||||
async def _a2a_gateway_handler(data: dict):
|
||||
return await gateway_message(GatewayRequest(**data))
|
||||
|
||||
install_a2a(
|
||||
app, _a2a_gateway_handler, registry, settings.A2A_PUBLIC_URL,
|
||||
settings.A2A_SECURITY_SCHEMES or None, settings.A2A_SECURITY or None,
|
||||
task_mode=settings.A2A_TASK_MODE, timeout_seconds=settings.A2A_TASK_TIMEOUT_SECONDS,
|
||||
)
|
||||
40
apps/agent_gateway/app/routes/governed_proxy_example.py
Normal file
40
apps/agent_gateway/app/routes/governed_proxy_example.py
Normal 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
|
||||
77
apps/agent_gateway/app/settings.py
Normal file
77
apps/agent_gateway/app/settings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
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"
|
||||
|
||||
ENABLE_A2A: bool = False
|
||||
A2A_TASK_MODE: bool = True
|
||||
A2A_TASK_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||
A2A_PUBLIC_URL: str = "http://localhost:8010"
|
||||
A2A_SECURITY_SCHEMES: dict = Field(default_factory=dict)
|
||||
A2A_SECURITY: list[dict[str, list[str]]] = Field(default_factory=list)
|
||||
|
||||
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"
|
||||
OCI_COMPARTMENT_ID: str | None = None
|
||||
OCI_GENAI_PROJECT_OCID: str | None = None
|
||||
|
||||
OCI_AUTH_MODE: str = "config_file"
|
||||
OCI_CONFIG_FILE: str = "~/.oci/config"
|
||||
OCI_CONFIG_PROFILE: str = "DEFAULT"
|
||||
OCI_REGION: str | None = None
|
||||
|
||||
# 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()
|
||||
102
apps/agent_gateway/app/supervisor_llm.py
Normal file
102
apps/agent_gateway/app/supervisor_llm.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Gateway-only bootstrap for the Global Supervisor LLM.
|
||||
|
||||
Resolve the supervisor entry in llm_profiles.yaml *before* create_llm().
|
||||
This matters because create_llm() selects OCISDKProvider immediately when
|
||||
LLM_PROVIDER=oci_sdk; OCISDKProvider does not dynamically switch providers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from agent_framework.llm.providers import create_llm
|
||||
|
||||
logger = logging.getLogger("agent_gateway.supervisor_llm")
|
||||
|
||||
|
||||
def _profile_file(settings) -> Path:
|
||||
explicit = (
|
||||
os.getenv("GLOBAL_SUPERVISOR_LLM_PROFILES_PATH")
|
||||
or getattr(settings, "GLOBAL_SUPERVISOR_LLM_PROFILES_PATH", None)
|
||||
or os.getenv("LLM_PROFILES_PATH")
|
||||
or getattr(settings, "LLM_PROFILES_PATH", None)
|
||||
)
|
||||
if explicit:
|
||||
path = Path(str(explicit)).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"Supervisor LLM profile YAML not found: {path}")
|
||||
return path
|
||||
|
||||
candidates = (
|
||||
"config/llm_profiles.yaml",
|
||||
"llm_profiles.yaml",
|
||||
"apps/agent_gateway/config/llm_profiles.yaml",
|
||||
"apps/agent_template_backend/config/llm_profiles.yaml",
|
||||
"apps/agent_template_backend/llm_profiles.yaml",
|
||||
)
|
||||
for candidate in candidates:
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return path.resolve()
|
||||
raise RuntimeError(
|
||||
"No llm_profiles.yaml found for Gateway supervisor. Set "
|
||||
"GLOBAL_SUPERVISOR_LLM_PROFILES_PATH to the EXISTING YAML file path."
|
||||
)
|
||||
|
||||
|
||||
def build_supervisor_llm(settings):
|
||||
"""Create LLM from the supervisor profile without mutating global settings."""
|
||||
path = _profile_file(settings)
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
profile = (data.get("profiles") or {}).get("supervisor")
|
||||
if not isinstance(profile, dict):
|
||||
raise RuntimeError(f"Missing profiles.supervisor in {path}")
|
||||
|
||||
effective_provider = str(profile.get("provider") or settings.LLM_PROVIDER).strip()
|
||||
allowed = {"oci_openai", "openai_compatible", "oci_sdk", "mock"}
|
||||
if effective_provider not in allowed:
|
||||
raise RuntimeError(f"Unsupported supervisor provider: {effective_provider}")
|
||||
|
||||
configured_url = (
|
||||
profile.get("base_url")
|
||||
or profile.get("service_endpoint")
|
||||
or getattr(settings, "OCI_GENAI_BASE_URL", None)
|
||||
)
|
||||
url = str(configured_url or "").strip().rstrip("/")
|
||||
if effective_provider == "oci_openai":
|
||||
# OCI native URL is commonly used by the process-wide .env when the
|
||||
# default provider is oci_sdk. Translate only the well-known OCI host.
|
||||
if url.startswith("https://inference.generativeai.") and url.endswith(".oci.oraclecloud.com"):
|
||||
url += "/openai/v1"
|
||||
if not url.endswith("/openai/v1"):
|
||||
raise RuntimeError(
|
||||
f"Supervisor OpenAI-compatible base URL must end with /openai/v1, got {url!r}. "
|
||||
"Set base_url under profiles.supervisor to the working endpoint."
|
||||
)
|
||||
|
||||
key = profile.get("api_key") or getattr(settings, "OCI_GENAI_API_KEY", None) or os.getenv("OCI_GENAI_API_KEY")
|
||||
if effective_provider in {"oci_openai", "openai_compatible"} and not key:
|
||||
raise RuntimeError(
|
||||
"Supervisor profile requires OCI_GENAI_API_KEY (or profiles.supervisor.api_key). "
|
||||
"OCI SDK config_file signing is NOT an OpenAI-compatible bearer API key."
|
||||
)
|
||||
|
||||
overrides = {
|
||||
"LLM_PROVIDER": effective_provider,
|
||||
"OCI_GENAI_MODEL": str(profile.get("model") or settings.OCI_GENAI_MODEL),
|
||||
"OCI_GENAI_BASE_URL": url,
|
||||
"LLM_TEMPERATURE": float(profile.get("temperature", settings.LLM_TEMPERATURE)),
|
||||
"LLM_MAX_TOKENS": int(profile.get("max_tokens", settings.LLM_MAX_TOKENS)),
|
||||
}
|
||||
if key:
|
||||
overrides["OCI_GENAI_API_KEY"] = str(key)
|
||||
|
||||
supervisor_settings = settings.model_copy(update=overrides)
|
||||
logger.info(
|
||||
"Supervisor LLM profile loaded path=%s provider=%s model=%s base_url=%s",
|
||||
path, effective_provider, overrides["OCI_GENAI_MODEL"], url,
|
||||
)
|
||||
return create_llm(supervisor_settings)
|
||||
39
apps/agent_gateway/config/authentication.example.yaml
Normal file
39
apps/agent_gateway/config/authentication.example.yaml
Normal 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
|
||||
27
apps/agent_gateway/config/backends.a2a.example.yaml
Normal file
27
apps/agent_gateway/config/backends.a2a.example.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
#default_backend: native
|
||||
#backends:
|
||||
# native:
|
||||
# url: http://localhost:8000
|
||||
# description: Agente nativo do framework
|
||||
# keywords: [conta, fatura]
|
||||
# # protocol omitido equivale a native
|
||||
# external:
|
||||
# protocol: a2a
|
||||
# a2a_version: '1.0' # ou '0.3'
|
||||
# url: https://external-agent.example
|
||||
# a2a_rpc_path: /a2a # endpoint definido pelo operador a partir do Agent Card
|
||||
# a2a_agent_card_path: /.well-known/agent-card.json
|
||||
# description: Agente externo A2A
|
||||
# keywords: [suporte]
|
||||
# a2a_headers_env:
|
||||
# Authorization: EXTERNAL_AGENT_AUTHORIZATION # valor completo: Bearer <token>
|
||||
|
||||
default_backend: agent_template
|
||||
|
||||
backends:
|
||||
agent_template:
|
||||
protocol: a2a
|
||||
a2a_version: "1.0"
|
||||
url: http://localhost:8000
|
||||
a2a_rpc_path: /a2a
|
||||
a2a_agent_card_path: /.well-known/agent-card.json
|
||||
38
apps/agent_gateway/config/backends.yaml
Normal file
38
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
|
||||
56
apps/agent_gateway/config/gateway_governance.yaml
Normal file
56
apps/agent_gateway/config/gateway_governance.yaml
Normal 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
|
||||
93
apps/agent_gateway/config/llm_profiles.yaml
Normal file
93
apps/agent_gateway/config/llm_profiles.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
profiles:
|
||||
default:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
max_tokens: 2048
|
||||
supervisor:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
route_continuity:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 80
|
||||
timeout_seconds: 5
|
||||
processing_interruption_classifier:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 8
|
||||
timeout_seconds: 5
|
||||
router:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 768
|
||||
guardrail:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 600
|
||||
grl:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
judge:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 800
|
||||
rag_rewriter:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 300
|
||||
rag_compressor:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 1200
|
||||
rag_generation:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.1
|
||||
max_tokens: 1800
|
||||
summary_memory:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.1
|
||||
max_tokens: 1200
|
||||
noc:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0
|
||||
max_tokens: 700
|
||||
billing_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
product_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
backoffice_agent:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1
|
||||
temperature: 0.2
|
||||
mcp_parameter_extraction:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 80
|
||||
timeout_seconds: 5
|
||||
|
||||
transaction_parameter_extraction:
|
||||
provider: oci_openai
|
||||
model: openai.gpt-4.1-mini
|
||||
temperature: 0
|
||||
max_tokens: 500
|
||||
timeout_seconds: 8
|
||||
31
apps/agent_gateway/docs/ARQUITETURA_GLOBAL_SUPERVISOR.md
Normal file
31
apps/agent_gateway/docs/ARQUITETURA_GLOBAL_SUPERVISOR.md
Normal 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.
|
||||
7
apps/agent_gateway/requirements.txt
Normal file
7
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
|
||||
17
apps/channel_gateway/.env.example
Normal file
17
apps/channel_gateway/.env.example
Normal 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=
|
||||
9
apps/channel_gateway/Dockerfile
Normal file
9
apps/channel_gateway/Dockerfile
Normal 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"]
|
||||
112
apps/channel_gateway/README.md
Normal file
112
apps/channel_gateway/README.md
Normal 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.
|
||||
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal 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: ...
|
||||
45
apps/channel_gateway/app/adapters/voice.py
Normal file
45
apps/channel_gateway/app/adapters/voice.py
Normal 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)
|
||||
35
apps/channel_gateway/app/adapters/web.py
Normal file
35
apps/channel_gateway/app/adapters/web.py
Normal 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,
|
||||
)
|
||||
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal file
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal 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)
|
||||
33
apps/channel_gateway/app/client.py
Normal file
33
apps/channel_gateway/app/client.py
Normal 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}
|
||||
112
apps/channel_gateway/app/main.py
Normal file
112
apps/channel_gateway/app/main.py
Normal 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)
|
||||
74
apps/channel_gateway/app/schemas.py
Normal file
74
apps/channel_gateway/app/schemas.py
Normal 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)
|
||||
46
apps/channel_gateway/app/settings.py
Normal file
46
apps/channel_gateway/app/settings.py
Normal 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()
|
||||
13
apps/channel_gateway/config/channels.yaml
Normal file
13
apps/channel_gateway/config/channels.yaml
Normal 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
|
||||
7
apps/channel_gateway/docker-compose.yml
Normal file
7
apps/channel_gateway/docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
channel_gateway:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "7000:7000"
|
||||
6
apps/channel_gateway/requirements.txt
Normal file
6
apps/channel_gateway/requirements.txt
Normal 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
|
||||
3
apps/mcp_gateway/.env.example
Normal file
3
apps/mcp_gateway/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
MCP_GATEWAY_CONFIG_PATH=config/mcp_gateway.yaml
|
||||
MCP_GATEWAY_HOST=0.0.0.0
|
||||
MCP_GATEWAY_PORT=8300
|
||||
15
apps/mcp_gateway/Dockerfile
Normal file
15
apps/mcp_gateway/Dockerfile
Normal 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"]
|
||||
13
apps/mcp_gateway/README.md
Normal file
13
apps/mcp_gateway/README.md
Normal 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.
|
||||
1
apps/mcp_gateway/app/__init__.py
Normal file
1
apps/mcp_gateway/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
424
apps/mcp_gateway/app/main.py
Normal file
424
apps/mcp_gateway/app/main.py
Normal 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")},
|
||||
)
|
||||
39
apps/mcp_gateway/config/authentication.example.yaml
Normal file
39
apps/mcp_gateway/config/authentication.example.yaml
Normal 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
|
||||
216
apps/mcp_gateway/config/mcp_gateway.yaml
Normal file
216
apps/mcp_gateway/config/mcp_gateway.yaml
Normal 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
|
||||
15
apps/mcp_gateway/config/mcp_servers.yaml
Normal file
15
apps/mcp_gateway/config/mcp_servers.yaml
Normal 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
|
||||
5
apps/mcp_gateway/requirements.txt
Normal file
5
apps/mcp_gateway/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
pydantic>=2.6
|
||||
PyYAML>=6.0
|
||||
httpx>=0.27
|
||||
Reference in New Issue
Block a user