A2A Protocol

This commit is contained in:
2026-10-21 08:23:55 -03:00
parent 1942f78df8
commit e3bd48cf4b
32 changed files with 2141 additions and 12 deletions

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='✳'; 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>Agent Workspace · A2A</title><link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="workspace">
<aside class="sidebar">
<a class="brand" href="./"><span class="brand-mark" aria-hidden="true">a</span><span>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="welcome-icon" aria-hidden="true">✳</span><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 @@
{"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,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

@@ -71,3 +71,13 @@ O Gateway emite eventos de observabilidade:
- `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

@@ -219,6 +219,10 @@ 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"
@@ -250,6 +254,9 @@ async def gateway_events(session_id: str):
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)
@@ -325,4 +332,17 @@ async def gateway_events(session_id: str):
"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

@@ -3,6 +3,7 @@ from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +17,13 @@ class GatewaySettings(BaseSettings):
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

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