First commit

This commit is contained in:
2026-10-30 22:04:44 -03:00
commit 645ecdfb07
2794 changed files with 282545 additions and 0 deletions

192
apps/agent_frontend/app.js Normal file
View 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");
}
});

View File

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

View File

@@ -0,0 +1 @@
body{font-family:system-ui,Arial,sans-serif;background:#f6f7fb;margin:0;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%}}

View 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

View 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.

View 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;
}

View 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();

View 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>

View 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

View File

@@ -0,0 +1 @@
{"name":"agent-framework-a2a-frontend","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}

File diff suppressed because one or more lines are too long

View 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');
});

View 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');
});

View File

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

View File

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

View File

@@ -0,0 +1,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"]

View 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.

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,
)

View File

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

View File

@@ -0,0 +1,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()

View 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)

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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