A2A Protocol

This commit is contained in:
2026-10-21 08:23:55 -03:00
parent b08affa48c
commit 3765c3f43a
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

248
docs/A2A_ASSINCRONO.md Normal file
View File

@@ -0,0 +1,248 @@
# Agentes do framework com A2A assíncrono
## O que foi implementado
O agente criado a partir de `templates/agent_template_backend` pode expor o
protocolo nativo e A2A no mesmo processo. O Agent Gateway também oferece a
mesma fachada A2A. As duas fachadas usam o SDK oficial e suas operações de
Tasks/SSE, com interoperabilidade A2A 1.0 e 0.3.
```text
Consumidor A2A ── Gateway A2A (Task)
├── agente nativo (/gateway/message)
└── agente framework A2A (Task remota)
Consumidor A2A ── agente framework A2A diretamente
```
O envio imediato devolve uma Task antes de terminar a operação de negócio.
A execução continua após a resposta HTTP e também após a desconexão do SSE.
O consumidor pode consultar o resultado ou se reconectar à tarefa em andamento.
Os endpoints nativos conservam seu comportamento; `/gateway/message` continua
aguardando o resultado, e o SSE nativo permanece separado do SSE A2A.
## Habilitar no agente construído com o framework
Na raiz do framework, instale a dependência opcional:
```sh
python -m pip install -e 'libs/agent_framework[a2a]' -r templates/agent_template_backend/requirements.txt
```
No ambiente do backend:
```dotenv
ENABLE_A2A=true
A2A_TASK_MODE=true
A2A_PUBLIC_URL=http://localhost:8000
A2A_TASK_TIMEOUT_SECONDS=240
```
Inicie o backend com suas configurações normais. Por exemplo, após preparar o
`.env` e os arquivos de configuração do agente:
```sh
cd templates/agent_template_backend
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
O endereço público real deve ser usado em `A2A_PUBLIC_URL`. A configuração de
exemplo está em `.env.a2a.example`. Há também uma imagem opcional, construída
na raiz do framework:
```sh
docker build -f templates/agent_template_backend/Dockerfile.a2a -t framework-agent-a2a .
```
Os perfis de `agents.yaml` são publicados como skills no Agent Card:
`GET /.well-known/agent-card.json`. Use `message.metadata.agent_id` para escolher
um perfil; sem ele, o backend mantém sua seleção padrão.
## Registrar esse agente no Agent Gateway
```yaml
backends:
pedidos_a2a:
protocol: a2a
a2a_version: '1.0'
url: http://backend-pedidos:8000
a2a_rpc_path: /a2a
description: Consulta e acompanhamento de pedidos
keywords: [pedido, entrega, rastreamento]
default_agent_id: pedidos
```
No gateway, habilite `ENABLE_A2A=true`, `A2A_TASK_MODE=true` e configure seu próprio
`A2A_PUBLIC_URL`. Para uma execução mais longa, ajuste os limites, por exemplo:
`A2A_TASK_TIMEOUT_SECONDS=300` e `BACKEND_TIMEOUT_SECONDS=270`, com o timeout do
backend em 240 segundos. A duração efetiva é limitada pelo menor prazo aplicável.
O gateway devolve sua própria Task imediatamente e consulta a Task remota até
conclusão ou solicitação de input/autenticação. Eventos de status remoto são
publicados como `a2a.backend.status`. Os IDs local e remoto podem ser distintos.
O resultado nativo inclui `metadata.a2a_task_id` e `metadata.a2a_context_id` para
correlação; o consumidor externo continua acompanhando o ID emitido pelo gateway.
## Envio assíncrono A2A 1.0
Envie um `POST /a2a` com `Content-Type: application/json` e
`A2A-Version: 1.0`:
```json
{
"jsonrpc": "2.0",
"id": "request-1",
"method": "SendMessage",
"params": {
"configuration": {"returnImmediately": true},
"message": {
"messageId": "message-1",
"contextId": "conversation-123",
"role": "ROLE_USER",
"parts": [{"text": "Consulte meu pedido"}],
"metadata": {"agent_id": "pedidos", "tenant_id": "default"}
}
}
}
```
A resposta contém `result.task.id`, `contextId` e um estado inicial como
`TASK_STATE_SUBMITTED` ou `TASK_STATE_WORKING`. Guarde esse ID e consulte:
```json
{
"jsonrpc": "2.0",
"id": "request-2",
"method": "GetTask",
"params": {"id": "ID_DA_TAREFA"}
}
```
O resultado está em `result`. Quando `status.state` for `TASK_STATE_COMPLETED`,
o conteúdo final estará em `artifacts[].parts`. Há uma parte de texto e uma
parte `data` com a resposta original do framework.
Sem `returnImmediately: true`, o envio aguarda o estado final ou interrompido,
retornando uma Task no mesmo formato. Para manter o comportamento da primeira
entrega A2A, que retornava Message, configure `A2A_TASK_MODE=false`; nesse modo,
streaming fica desabilitado e não há Tasks locais.
## Streaming e reconexão
Para enviar e acompanhar, use `SendStreamingMessage` no lugar de `SendMessage`
com os mesmos parâmetros. A resposta tem `Content-Type: text/event-stream`.
Cada campo `data:` contém uma resposta JSON-RPC. Os eventos contêm:
1. A Task inicial.
2. Atualizações de status: submitted/working e eventos de progresso.
3. O artefato de resposta.
4. O estado completed, failed, canceled, input-required ou auth-required.
O backend do framework publica marcos como `flow.start`, `session.upserted`,
`workflow.started`, `workflow.completed` e `message.responded`, quando o fluxo
passa por essas etapas. Eles aparecem em
`result.statusUpdate.metadata.framework_event`, com os dados correspondentes
em `framework_payload`. Isso não implica transmissão de tokens do LLM.
Para se reconectar a uma Task em andamento:
```json
{
"jsonrpc": "2.0",
"id": "request-3",
"method": "SubscribeToTask",
"params": {"id": "ID_DA_TAREFA"}
}
```
A reconexão entrega o estado atual e os eventos seguintes; não há promessa de
replay de todos os eventos perdidos. Se a tarefa já terminou, consulte `GetTask`.
## Cancelamento, falhas e continuidade
`CancelTask`, com `params.id`, cancela a execução local em andamento. O SDK
interrompe a coroutine e registra `TASK_STATE_CANCELED`. Isso não desfaz efeitos
já confirmados em ferramentas ou sistemas externos. Quando o gateway está
consultando um agente remoto, parar a tarefa local não garante cancelar a
operação que já foi aceita pelo serviço remoto.
Exceções e expiração de `A2A_TASK_TIMEOUT_SECONDS` resultam em
`TASK_STATE_FAILED`, consultável depois, com mensagem genérica. Detalhes internos
permanecem nos logs do servidor.
Para uma interação que exige nova entrada, um handler pode retornar
`metadata.a2a_task_state: "input-required"` ou `"auth-required"`. O consumidor
envia outra mensagem com o mesmo `taskId` e `contextId`. A tarefa é retomada;
uma tarefa concluída não pode ser reexecutada. O adaptador de saída preserva a
continuidade de tarefas remotas nesses estados.
Os workflows nativos que não declaram esse estado concluem uma Task por turno;
a conversa continua com uma nova Task e o mesmo `contextId`, usando as sessões
e memórias já existentes no framework.
## Equivalentes A2A 0.3
Use `A2A-Version: 0.3`, `role: "user"` e os discriminadores `kind` de 0.3.
| A2A 1.0 | A2A 0.3 |
| --- | --- |
| `SendMessage` + `returnImmediately: true` | `message/send` + `blocking: false` |
| `SendStreamingMessage` | `message/stream` |
| `GetTask` | `tasks/get` |
| `SubscribeToTask` | `tasks/resubscribe` |
| `CancelTask` | `tasks/cancel` |
Em 0.3, `result` contém diretamente a Task, sem o envelope `task`. Os estados
usam `submitted`, `working`, `completed`, `failed`, etc.
## Armazenamento e autenticação
O padrão usa o TaskStore em memória do SDK. Tarefas e resultados são perdidos
quando o processo reinicia. Para múltiplos workers/réplicas, use afinidade de
requisições ao mesmo processo durante a execução ou configure armazenamento e
coordenação compartilhados. Um TaskStore persistente, sozinho, não torna a
execução retomável após falha. As funções `install_a2a` e `install_backend_a2a`
aceitam `task_store` para integração com um armazenamento do SDK.
O backend opcional reutiliza `AGENT_AUTH_*`; o gateway usa
`AGENT_GATEWAY_AUTH_*`. A identidade autenticada pelo middleware define o dono
das Tasks, incluindo consultas e cancelamento. Usuários anônimos compartilham
o escopo anônimo. `tenant_id` em metadata continua sendo dado do pedido, não
credencial nem isolamento de autorização. Clientes que compartilham uma mesma
credencial/principal compartilham o escopo de tarefas.
`A2A_SECURITY_SCHEMES` e `A2A_SECURITY` documentam os mecanismos no Agent Card,
como descrito no guia geral. SSE está habilitado no modo de Tasks;
notificações push e gRPC continuam fora desta implementação.
## Validação
A suíte inclui envio imediato enquanto o handler permanece bloqueado, polling,
streaming com progresso e resultado, cancelamento, falhas, timeout, retomada,
separação entre identidades autenticadas e gateway chamando um backend A2A real
em aplicação local. Há também testes HTTP em `127.0.0.1` que verificam a chegada
do primeiro evento antes da conclusão e reconexão após desconexão.
```sh
python -m pip install pytest pytest-asyncio uvicorn
PYTHONPATH=libs/agent_framework/src pytest \
tests/test_a2a_interoperability.py tests/test_a2a_async_tasks.py \
tests/test_a2a_http_streaming.py \
tests/unit/test_sse.py tests/unit/test_sse_replay_dedup.py \
tests/unit/test_authentication.py tests/unit/test_authentication_policies.py -q
```
Os testes não acessam OCI nem serviços de negócio externos. Em ambientes sem
permissão para abrir portas locais, execute o teste HTTP em um ambiente que
permita bind em loopback.
Especificação oficial: https://a2a-protocol.org/latest/specification/
SDK oficial: https://github.com/a2aproject/a2a-python
## Frontend pronto para A2A
A interface `apps/agent_frontend_a2a` consome este protocolo diretamente, com
Tasks, streaming, consulta e cancelamento. Consulte
[Frontend A2A](../apps/agent_frontend_a2a/README.md) para a demonstração sem OCI
e os comandos de execução com o backend ou Gateway. O frontend nativo continua
em `apps/agent_frontend`.

145
docs/A2A_IMPLEMENTACAO.md Normal file
View File

@@ -0,0 +1,145 @@
# Integração A2A no Agent Framework OCI
## Decisão de arquitetura
O Agent Gateway existente passa a ter uma fachada A2A opcional. Não é necessário
manter um segundo roteador, duplicar o supervisor ou migrar os agentes atuais.
```text
Consumidor atual ── /gateway/message ─┐
├─ Agent Gateway / supervisor ─┬─ backend native
Consumidor A2A ──── /a2a ─────────────┘ └─ backend a2a
```
O backend nativo continua recebendo `{channel, payload, agent_id, tenant_id}`.
A ausência de `protocol` no catálogo mantém `native`. A2A fica desativado por
padrão; o SDK só é importado ao habilitar a fachada ou selecionar um backend A2A.
Não foi alterado o runtime dos agentes, os workflows, os canais ou os MCPs.
A entrada usa A2A **1.0 / JSON-RPC**, SDK oficial `a2a-sdk==1.2.2`, com adaptação
0.3 disponibilizada pelo próprio SDK. Agentes externos podem ser registrados
com `a2a_version: '1.0'` ou `'0.3'`; não há detecção heurística de versão.
## Ativação local
Na raiz do framework:
```sh
python -m pip install -e 'libs/agent_framework[a2a]' -r apps/agent_gateway/requirements.txt
export ENABLE_A2A=true
export A2A_PUBLIC_URL=https://meu-gateway.example
export BACKENDS_CONFIG_PATH="$PWD/apps/agent_gateway/config/backends.a2a.example.yaml"
python -m uvicorn app.main:app --app-dir apps/agent_gateway --port 8010
```
Edite primeiro as URLs do catálogo de exemplo. Para agentes externos, informe o
endpoint JSON-RPC anunciado no Agent Card como `url` + `a2a_rpc_path`.
Credenciais ficam em variáveis de ambiente referenciadas por `a2a_headers_env`.
Os valores não são publicados em `/backends`.
A imagem opcional pode ser construída a partir da raiz:
```sh
docker build -f apps/agent_gateway/Dockerfile.a2a -t agent-gateway-a2a .
```
Defina `A2A_PUBLIC_URL` no ambiente do container e monte o catálogo de backends.
A imagem e os comandos anteriores de implantação continuam disponíveis.
## Descoberta e chamada
O Agent Card fica em `GET /.well-known/agent-card.json`; as skills são derivadas
dos backends habilitados. As mensagens chegam em `POST /a2a`.
Exemplo A2A 1.0, com cabeçalho `A2A-Version: 1.0`:
```json
{
"jsonrpc": "2.0",
"id": "request-1",
"method": "SendMessage",
"params": {
"message": {
"messageId": "message-1",
"contextId": "conversation-123",
"role": "ROLE_USER",
"parts": [
{"text": "Quero consultar minha fatura"},
{"data": {"customer_id": "123"}}
],
"metadata": {"tenant_id": "default"}
}
}
}
```
No modo de tarefas (padrão), a resposta fica em `result.task`. O resultado de
negócio está em `artifacts[].parts`, com texto e uma parte `data` contendo a
resposta do framework. Com `A2A_TASK_MODE=false`, permanece em `result.message`. Reutilize `contextId` nos próximos turnos. No modo 0.3,
use o cabeçalho `A2A-Version: 0.3`, método `message/send`, role `user` e os
campos `kind` da especificação 0.3; o resultado é uma Task diretamente
(ou Message com `A2A_TASK_MODE=false`).
Partes `data` devem conter objetos e são incorporadas ao payload de negócio.
A identidade da sessão e o texto do protocolo prevalecem sobre esses dados.
Arquivos recebidos pela fachada são rejeitados explicitamente.
## Conversas, tarefas e limites
- A fachada cria **Tasks A2A** e permite envio imediato, consulta, cancelamento
e streaming SSE. `A2A_TASK_MODE=false` mantém o modo anterior de Messages.
Consulte [A2A assíncrono](A2A_ASSINCRONO.md) para os exemplos completos.
- O adaptador de saída aceita Message ou Task; consulta `GetTask`/`tasks/get`
enquanto o agente está trabalhando, dentro de um prazo total configurável
por `BACKEND_TIMEOUT_SECONDS`. Não repete automaticamente operações de envio.
- Tarefas que pedem input/autenticação preservam estado e identidade para o
próximo turno. Tarefas concluídas não são reutilizadas.
- A continuidade remota é guardada no processo, por tenant/backend/sessão,
com validade de uma hora e limite de 10 mil entradas. Em múltiplas réplicas,
use afinidade de sessão ou implemente armazenamento compartilhado antes de
depender dessa continuidade. Reiniciar o processo limpa esse cache.
- `metadata.a2a_result` preserva o resultado remoto, incluindo dados/artefatos;
`a2a_context_id`, `a2a_task_id` e `a2a_state` permitem inspeção e continuidade
explícita no payload quando necessário.
- Streaming SSE está disponível no modo de tarefas. Push notifications e gRPC
não são oferecidos. O proxy de eventos nativo não é utilizado para agentes
A2A: use `SendStreamingMessage`/`SubscribeToTask` e seus equivalentes 0.3.
- O template `templates/agent_template_backend` também expõe A2A diretamente
quando `ENABLE_A2A=true`, sem passar pelo gateway global.
## Autenticação
As rotas A2A compartilham o middleware de autenticação já instalado no gateway
(`AGENT_GATEWAY_AUTH_*`). Habilitar A2A não habilita autenticação automaticamente.
O tenant em metadata mantém a semântica atual do framework: é dado do pedido,
não uma autorização concedida pelo protocolo. Políticas de acesso a tenants
continuam sendo responsabilidade da configuração de autenticação/autorização.
Quando houver autenticação, declare-a também no Agent Card, por exemplo:
```sh
export A2A_SECURITY_SCHEMES='{"gatewayBearer":{"httpAuthSecurityScheme":{"scheme":"bearer"}}}'
export A2A_SECURITY='[{"gatewayBearer":[]}]'
```
Essas declarações documentam o mecanismo; quem verifica as credenciais é o
middleware. Se desejar descoberta pública, adicione somente o caminho do Agent
Card à configuração de caminhos públicos; `/a2a` permanece protegido.
## Validação
Testes em `tests/test_a2a_interoperability.py` cobrem os dois formatos de entrada,
o contrato nativo, os dois formatos de saída, polling, erros JSON-RPC,
continuidade de tarefas, separação entre tenants e limite de tempo.
```sh
PYTHONPATH=libs/agent_framework/src pytest tests/test_a2a_interoperability.py -q
```
Validação com transportes HTTP simulados e aplicação FastAPI local; não substitui
um teste de integração com os serviços OCI e agentes externos da implantação.
Referências oficiais:
- https://a2a-protocol.org/latest/specification/
- https://a2a-protocol.org/v0.3.0/specification/
- https://github.com/a2aproject/a2a-python

View File

@@ -0,0 +1,43 @@
"""Local frontend demo, using the actual framework A2A adapter and SDK.
From the framework root:
pip install -e 'libs/agent_framework[a2a]' uvicorn
uvicorn examples.a2a_frontend_demo:app --host 127.0.0.1 --port 8000
This demo simulates business processing; it never calls OCI or an external tool.
"""
import asyncio
import os
from pathlib import Path
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from agent_framework.a2a.server import install_backend_a2a
from agent_framework.a2a.progress import report_progress
app = FastAPI(title='Agent Framework · A2A Frontend Demo')
async def demo_agent(request):
text = request['payload']['message']
await report_progress('workflow.started', {'agent': request.get('agent_id') or 'demo'})
await asyncio.sleep(1)
if text.strip().lower() == '/erro':
raise RuntimeError('Simulated demo error')
if text.strip().lower() == '/lento':
await asyncio.sleep(20)
await report_progress('workflow.completed', {'agent': 'demo'})
if text.strip().lower() == '/confirmar':
return {'text': 'Deseja continuar com a demonstração? Responda sim para prosseguir.',
'metadata': {'a2a_task_state': 'input-required', 'agent_id': 'demo'}}
return {'text': f'Recebi sua mensagem: “{text}”.\n\nA tarefa A2A foi concluída. Este exemplo usa o adaptador real do framework, com processamento de negócio simulado.',
'session_id': request['session_id'], 'metadata': {'agent_id': 'demo'}}
profiles = SimpleNamespace(list_profiles=lambda: [SimpleNamespace(
agent_id='demo', name='Agente de demonstração', description='Exemplo local de agente A2A assíncrono')])
settings = SimpleNamespace(A2A_PUBLIC_URL=os.getenv('A2A_PUBLIC_URL', 'http://localhost:8000'),
A2A_TASK_MODE=True, A2A_TASK_TIMEOUT_SECONDS=60, A2A_SECURITY_SCHEMES={}, A2A_SECURITY=[], APP_NAME='Agente de demonstração')
install_backend_a2a(app, demo_agent, profiles, settings)
app.mount('/', StaticFiles(directory=Path(__file__).resolve().parents[1] / 'apps' / 'agent_frontend_a2a', html=True), name='frontend')

View File

@@ -27,6 +27,9 @@ dependencies = [
"PyJWT[crypto]>=2.9.0"
]
[project.optional-dependencies]
a2a = ["a2a-sdk[http-server]==1.2.2"]
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -0,0 +1 @@
"""Optional A2A interoperability; imported only when enabled."""

View File

@@ -0,0 +1,108 @@
"""A2A JSON-RPC adapter. URLs and credential references are operator configured."""
import asyncio
import json
import os
from uuid import uuid4, uuid5, NAMESPACE_URL
import httpx
from google.protobuf.json_format import ParseDict, MessageToDict
from a2a.types import SendMessageResponse, Task
from .progress import report_progress
def _normalize_legacy(result):
# Preserve the original wire response separately; normalize only presentation.
if result.get('kind') == 'message':
return {'message': result}
if result.get('kind') == 'task':
return {'task': result}
raise ValueError('Expected an A2A Message or Task')
async def call_a2a(backend, request, timeout_seconds):
payload = request.get('payload') or {}
session = payload.get('session_id') or str(uuid4())
tenant = request.get('tenant_id') or 'default'
context_id = str(uuid5(NAMESPACE_URL, json.dumps([tenant, backend.backend_id, session])))
legacy = backend.a2a_version == '0.3'
text = str(payload.get('message') or payload.get('text') or payload.get('transcript') or payload.get('content') or '')
parts = [{'text': text}, {'data': payload}]
message = {'role': 'ROLE_USER', 'messageId': str(uuid4()),
'contextId': payload.get('a2a_context_id') or context_id, 'parts': parts,
'metadata': {'tenant_id': tenant, 'agent_id': request.get('agent_id') or backend.default_agent_id}}
if payload.get('a2a_task_id'):
message['taskId'] = payload['a2a_task_id']
if legacy:
message.update(kind='message', role='user')
for part in parts:
part['kind'] = 'text' if 'text' in part else 'data'
headers = {'A2A-Version': backend.a2a_version}
for header, env_name in backend.a2a_headers_env.items():
value = os.environ.get(env_name)
if not value:
raise ValueError(f'Missing credential environment variable: {env_name}')
headers[header] = value
endpoint = backend.base_url + backend.a2a_rpc_path
async with asyncio.timeout(timeout_seconds):
async with httpx.AsyncClient(timeout=timeout_seconds, headers=headers) as client:
async def rpc(method, params):
request_id = str(uuid4())
response = await client.post(endpoint, json={
'jsonrpc': '2.0', 'id': request_id, 'method': method, 'params': params,
})
response.raise_for_status()
data = response.json()
if data.get('jsonrpc') != '2.0' or data.get('id') != request_id:
raise ValueError('Invalid A2A JSON-RPC response')
if 'error' in data:
raise RuntimeError(f'A2A error {data["error"].get("code")}')
result = data.get('result')
if not isinstance(result, dict):
raise ValueError('Missing A2A result')
return result
raw = await rpc('message/send' if legacy else 'SendMessage', {
'message': message,
'configuration': {'blocking': False} if legacy else {'returnImmediately': True},
})
result = _normalize_legacy(raw) if legacy else raw
if not legacy:
result = MessageToDict(ParseDict(result, SendMessageResponse()))
while 'task' in result:
task = result['task']
state = task.get('status', {}).get('state', '')
await report_progress('a2a.backend.status', {
'backend_id': backend.backend_id, 'task_id': task.get('id'), 'state': state,
})
if state not in {'submitted', 'working', 'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'}:
break
await asyncio.sleep(backend.a2a_poll_interval_seconds)
raw = await rpc('tasks/get' if legacy else 'GetTask', {'id': task['id']})
result = {'task': raw if legacy else MessageToDict(ParseDict(raw, Task()))}
task = result.get('task')
if task:
state, task_id = task['status']['state'], task['id']
if state in {'failed', 'canceled', 'rejected', 'unknown', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED', 'TASK_STATE_UNSPECIFIED'}:
raise RuntimeError(f'A2A task ended with state: {state}')
output_parts = [p for a in task.get('artifacts', []) for p in a.get('parts', [])]
if not output_parts and task['status'].get('message'):
output_parts += task['status']['message'].get('parts', [])
if not output_parts:
messages = [m for m in task.get('history', []) if m.get('role') in {'agent', 'ROLE_AGENT'}]
output_parts = messages[-1].get('parts', []) if messages else []
remote_context = task.get('contextId')
elif result.get('message'):
reply = result['message']
output_parts = reply.get('parts', [])
state, task_id, remote_context = None, None, reply.get('contextId')
else:
raise ValueError('Expected an A2A Message or Task')
return {
'channel': request.get('channel', 'web'), 'session_id': session,
'text': '\n'.join(p['text'] for p in output_parts if 'text' in p),
'metadata': {'a2a_context_id': remote_context, 'a2a_task_id': task_id,
'a2a_state': state, 'a2a_result': raw},
}

View File

@@ -0,0 +1,10 @@
"""Task-local progress hook; safe to import without the optional A2A SDK."""
from contextvars import ContextVar
_progress_callback = ContextVar('framework_a2a_progress', default=None)
async def report_progress(event: str, payload: dict | None = None):
callback = _progress_callback.get()
if callback is not None:
await callback(event, payload or {})

View File

@@ -0,0 +1,181 @@
"""A2A 1.0 Tasks and SSE with SDK-managed 0.3 compatibility."""
import asyncio
import logging
from uuid import uuid4
from google.protobuf.json_format import MessageToDict, ParseDict
from a2a.auth.user import User
from a2a.server.agent_execution import AgentExecutor
from a2a.server.routes import (
DefaultServerCallContextBuilder, add_a2a_routes_to_fastapi,
create_agent_card_routes, create_jsonrpc_routes,
)
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCard, Message, Part, Role, Task, TaskState, TaskStatus
from a2a.utils.errors import InvalidParamsError, TaskNotCancelableError, UnsupportedOperationError
from .progress import _progress_callback
logger = logging.getLogger(__name__)
class _PrincipalUser(User):
def __init__(self, principal):
self.principal = principal
@property
def is_authenticated(self):
return True
@property
def user_name(self):
# Use middleware identity, never client-supplied message metadata.
return f'{self.principal.scheme}:{self.principal.subject}'
class FrameworkCallContextBuilder(DefaultServerCallContextBuilder):
def build_user(self, request):
principal = getattr(request.state, 'auth_principal', None)
return _PrincipalUser(principal) if principal else super().build_user(request)
def _response_message(response, context_id, task_id=None):
data = {
'messageId': str(uuid4()), 'role': 'ROLE_AGENT', 'contextId': context_id,
'parts': [{'text': str(response.get('text') or response.get('speak') or '')},
{'data': response}],
}
if task_id:
data['taskId'] = task_id
return ParseDict(data, Message())
class GatewayExecutor(AgentExecutor):
"""Wrap any native async handler as an A2A task, without detached worker hacks."""
def __init__(self, handler, task_mode=True, timeout_seconds=120.0):
self.handler = handler
self.task_mode = task_mode
self.timeout_seconds = timeout_seconds
async def execute(self, context, event_queue):
message = context.message
if message is None or message.role != Role.ROLE_USER:
raise InvalidParamsError('Expected a user message')
payload = {}
for part in MessageToDict(message).get('parts', []):
if 'data' in part:
if not isinstance(part['data'], dict):
raise InvalidParamsError('Structured data must be an object')
payload.update(part['data'])
elif 'text' not in part:
raise UnsupportedOperationError('Only text and structured data are supported')
metadata = MessageToDict(message.metadata)
session_id = context.context_id or str(uuid4())
payload.update(message=context.get_user_input(), session_id=session_id)
request = {
'channel': 'web', 'payload': payload, 'session_id': session_id,
'tenant_id': metadata.get('tenant_id'), 'agent_id': metadata.get('agent_id'),
'metadata': metadata, 'backend_id': metadata.get('backend_id'),
}
if not self.task_mode:
async with asyncio.timeout(self.timeout_seconds):
response = await self.handler(request)
await event_queue.enqueue_event(_response_message(response, session_id))
return
updater = TaskUpdater(event_queue, context.task_id, session_id)
if context.current_task is None:
await event_queue.enqueue_event(Task(
id=context.task_id, context_id=session_id,
status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
history=[message],
))
await updater.update_status(TaskState.TASK_STATE_WORKING)
async def progress(event, data):
await updater.update_status(TaskState.TASK_STATE_WORKING, metadata={
'framework_event': event, 'framework_payload': data,
})
token = _progress_callback.set(progress)
try:
async with asyncio.timeout(self.timeout_seconds):
response = await self.handler(request)
reply = _response_message(response, session_id, context.task_id)
response_metadata = response.get('metadata') or {}
requested_state = response_metadata.get('a2a_state') or response_metadata.get('a2a_task_state')
state = {
'input-required': TaskState.TASK_STATE_INPUT_REQUIRED,
'auth-required': TaskState.TASK_STATE_AUTH_REQUIRED,
'TASK_STATE_INPUT_REQUIRED': TaskState.TASK_STATE_INPUT_REQUIRED,
'TASK_STATE_AUTH_REQUIRED': TaskState.TASK_STATE_AUTH_REQUIRED,
}.get(requested_state, TaskState.TASK_STATE_COMPLETED)
await updater.add_artifact(list(reply.parts), artifact_id='response', name='Framework response', last_chunk=True)
await updater.update_status(state, message=reply)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception('A2A task %s failed', context.task_id)
text = 'Processing timed out' if isinstance(exc, TimeoutError) else 'Agent processing failed'
await updater.update_status(TaskState.TASK_STATE_FAILED, message=_response_message({'text': text}, session_id, context.task_id))
finally:
_progress_callback.reset(token)
async def cancel(self, context, event_queue):
if not self.task_mode:
raise TaskNotCancelableError()
# The SDK cancels the running execute coroutine after this callback.
await TaskUpdater(event_queue, context.task_id, context.context_id).update_status(TaskState.TASK_STATE_CANCELED)
def install_a2a(app, handler, registry, public_url, security_schemes=None, security=None,
*, task_mode=True, timeout_seconds=120.0, task_store=None,
name='Agent Framework OCI Gateway', skills=None):
"""Reuse parent middleware. A persistent SDK TaskStore may be injected."""
if timeout_seconds <= 0:
raise ValueError('A2A timeout_seconds must be positive')
if skills is None:
skills = [{'id': b.backend_id, 'name': b.name or b.backend_id,
'description': b.description or b.backend_id,
'tags': b.domains or [b.backend_id], 'examples': b.examples}
for b in registry.list()]
card = ParseDict({
'name': name, 'version': '1.1.0',
'description': 'Native framework agents and A2A interoperability',
'supportedInterfaces': [{'url': public_url.rstrip('/') + '/a2a',
'protocolBinding': 'JSONRPC', 'protocolVersion': '1.0'}],
'capabilities': {'streaming': task_mode, 'pushNotifications': False},
'defaultInputModes': ['text/plain', 'application/json'],
'defaultOutputModes': ['text/plain', 'application/json'],
'securitySchemes': security_schemes or {},
'securityRequirements': [{'schemes': {k: {'list': v} for k, v in item.items()}}
for item in (security or [])],
'skills': skills,
}, AgentCard())
request_handler = DefaultRequestHandler(
GatewayExecutor(handler, task_mode=task_mode, timeout_seconds=timeout_seconds),
task_store if task_store is not None else InMemoryTaskStore(), card,
)
add_a2a_routes_to_fastapi(
app, agent_card_routes=create_agent_card_routes(card),
jsonrpc_routes=create_jsonrpc_routes(
request_handler, rpc_url='/a2a', enable_v0_3_compat=True,
context_builder=FrameworkCallContextBuilder(),
),
)
return request_handler
def install_backend_a2a(app, handler, profiles, settings, *, task_store=None):
"""Expose a framework backend directly, with skills built from agent profiles."""
return install_a2a(
app, handler, None, 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,
task_store=task_store, name=settings.APP_NAME,
skills=[{'id': p.agent_id, 'name': p.name or p.agent_id,
'description': p.description or p.agent_id, 'tags': [p.agent_id]}
for p in profiles.list_profiles()],
)

View File

@@ -14,6 +14,13 @@ load_dotenv(override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
ENABLE_A2A: bool = False
A2A_PUBLIC_URL: str = 'http://localhost:8000'
A2A_TASK_MODE: bool = True
A2A_TASK_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
A2A_SECURITY_SCHEMES: dict = Field(default_factory=dict)
A2A_SECURITY: list[dict[str, list[str]]] = Field(default_factory=list)
APP_NAME: str = 'ai-agent-template'
APP_ENV: str = 'local'
LOG_LEVEL: str = 'INFO'

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import time
import os
from collections import OrderedDict
from typing import Any
import httpx
@@ -11,6 +13,8 @@ from .models import BackendCallResult, BackendDefinition, GlobalRouteDecision
class BackendClient:
def __init__(self, timeout_seconds: float = 120.0):
self.timeout_seconds = timeout_seconds
# Bounded, process-local continuation cache; native calls do not use it.
self._a2a_sessions = OrderedDict()
async def call_message(
self,
@@ -19,6 +23,27 @@ class BackendClient:
route_decision: GlobalRouteDecision,
use_sse: bool = False,
) -> BackendCallResult:
if backend.protocol == "a2a":
from agent_framework.a2a.client import call_a2a
started = time.monotonic()
payload = dict(request_payload.get("payload") or {})
key = (request_payload.get("tenant_id") or "default", backend.backend_id, payload.get("session_id"))
previous = self._a2a_sessions.get(key) if key[2] else None
if previous and time.monotonic() - previous[0] < 3600:
payload.setdefault("a2a_context_id", previous[1].get("a2a_context_id"))
if previous[1].get("a2a_state") in {"input-required", "auth-required", "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED"}:
payload.setdefault("a2a_task_id", previous[1].get("a2a_task_id"))
data = await call_a2a(backend, {**request_payload, "payload": payload}, self.timeout_seconds)
if key[2]:
self._a2a_sessions[key] = (time.monotonic(), data.get("metadata", {}))
self._a2a_sessions.move_to_end(key)
while len(self._a2a_sessions) > 10000:
self._a2a_sessions.popitem(last=False)
return BackendCallResult(
backend_id=backend.backend_id, backend_url=backend.base_url,
status_code=200, response=data, route_decision=route_decision,
elapsed_ms=int((time.monotonic() - started) * 1000),
)
path = backend.sse_message_path if use_sse else backend.message_path
url = f"{backend.base_url}{path}"
payload = dict(request_payload)
@@ -45,8 +70,16 @@ class BackendClient:
)
async def health(self, backend: BackendDefinition) -> dict[str, Any]:
url = f"{backend.base_url}{backend.health_path}"
async with httpx.AsyncClient(timeout=10.0) as client:
path = backend.a2a_agent_card_path if backend.protocol == "a2a" else backend.health_path
url = f"{backend.base_url}{path}"
headers = {}
if backend.protocol == "a2a":
for header, env_name in backend.a2a_headers_env.items():
value = os.environ.get(env_name)
if not value:
return {"backend_id": backend.backend_id, "ok": False, "error": f"Missing credential environment variable: {env_name}"}
headers[header] = value
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
try:
resp = await client.get(url)
return {"backend_id": backend.backend_id, "status_code": resp.status_code, "ok": resp.is_success, "body": self._safe_json(resp)}

View File

@@ -21,6 +21,12 @@ class BackendDefinition(BaseModel):
examples: list[str] = Field(default_factory=list)
priority: int = 100
enabled: bool = True
protocol: Literal["native", "a2a"] = "native"
a2a_version: Literal["1.0", "0.3"] = "1.0"
a2a_agent_card_path: str = "/.well-known/agent-card.json"
a2a_rpc_path: str = "/a2a"
a2a_headers_env: dict[str, str] = Field(default_factory=dict)
a2a_poll_interval_seconds: float = Field(default=0.5, gt=0)
health_path: str = "/health"
message_path: str = "/gateway/message"
sse_message_path: str = "/gateway/message/sse"

View File

@@ -0,0 +1,9 @@
# Complemento opcional ao .env do agente. Ajuste para o endereço público real.
ENABLE_A2A=true
A2A_TASK_MODE=true
A2A_PUBLIC_URL=http://localhost:8000
A2A_TASK_TIMEOUT_SECONDS=240
# AGENT_AUTH_* configura o middleware já fornecido pelo framework.
# Exemplo de declaração no Agent Card (não habilita autenticação):
# A2A_SECURITY_SCHEMES={"gatewayBearer":{"httpAuthSecurityScheme":{"scheme":"bearer"}}}
# A2A_SECURITY=[{"gatewayBearer":[]}]

View File

@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
COPY libs/agent_framework /agent_framework
COPY templates/agent_template_backend /app
RUN pip install --no-cache-dir -e '/agent_framework[a2a]' -r requirements.txt
ENV ENABLE_A2A=true
ENV A2A_TASK_MODE=true
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -4226,3 +4226,10 @@ Este template inclui o arquivo opcional `config/tool_policies.yaml`. Use `operat
Além da execução direta de MCP tools, uma operação transacional pode usar um workflow LangGraph determinístico após `clarification` e confirmação explícita. Configure `execution.mode: workflow` em `config/tool_policies.yaml`, mantenha as definições versionadas em `workflows/` e implemente as actions do domínio no projeto do agente. O runtime genérico está em `agent_framework.workflows`.
Consulte `libs/agent_framework/docs/TRANSACTIONAL_WORKFLOWS_PT.md` e o exemplo `workflows/devolucao_pedido.v1.yaml`.
## Agente A2A assíncrono
O template também pode expor `/a2a`, com Tasks, consulta de status e streaming
SSE, usando `ENABLE_A2A=true` e `A2A_TASK_MODE=true`. Consulte
[A2A assíncrono](../../docs/A2A_ASSINCRONO.md) para ativação e integração ao Gateway.
Os endpoints nativos permanecem disponíveis.

View File

@@ -168,7 +168,14 @@ def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, Bu
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
async def _emit_gateway_event(session_id, event, payload, emit_sse, progress):
if emit_sse:
await sse_hub.emit(session_id, event, payload)
if progress is not None:
await progress(event, payload)
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False, progress=None) -> dict:
try:
msg = await gateway.normalize(req.channel, req.payload)
except ValueError as exc:
@@ -191,7 +198,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
await _emit_gateway_event(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}, emit_sse, progress)
session = await sessions.get(agent_session_id)
if not session:
@@ -296,11 +303,11 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
)
rendered = await gateway.render(response)
await telemetry.event("gateway.message.replayed", {"session_id": agent_session_id, "reason": interruption.reason})
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await _emit_gateway_event(agent_session_id, "message.responded", rendered, emit_sse, progress)
return rendered
effective_text = interruption.text
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await _emit_gateway_event(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}, emit_sse, progress)
await memory.append(
agent_session_id,
@@ -317,7 +324,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
await _emit_gateway_event(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}, emit_sse, progress)
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
cms_input = {
@@ -362,7 +369,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
_root_span=True,
) as root_span:
await telemetry.event("gateway.message.received", trace_context)
await sse_hub.emit(agent_session_id, "workflow.started", trace_context) if emit_sse else None
await _emit_gateway_event(agent_session_id, "workflow.started", trace_context, emit_sse, progress)
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
@@ -396,7 +403,7 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
await _emit_gateway_event(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}, emit_sse, progress)
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
@@ -480,8 +487,8 @@ async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False)
)
rendered = await gateway.render(response)
root_span.set_output(rendered)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
await _emit_gateway_event(agent_session_id, "message.responded", rendered, emit_sse, progress)
await _emit_gateway_event(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}, emit_sse, progress)
return rendered
@@ -649,3 +656,17 @@ async def get_session_checkpoint(session_id: str):
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()
# Optional direct A2A endpoint for agents built with the framework template.
if settings.ENABLE_A2A:
from agent_framework.a2a.progress import report_progress
from agent_framework.a2a.server import install_backend_a2a
from agent_framework.security import install_authentication
install_authentication(app, prefix="AGENT_AUTH")
async def _a2a_backend_handler(data: dict):
return await _process_gateway_message(GatewayRequest(**data), progress=report_progress)
install_backend_a2a(app, _a2a_backend_handler, agent_profiles, settings)

View File

@@ -0,0 +1,258 @@
"""Real SDK/FastAPI tests: no mock task manager or custom JSON-RPC dispatcher."""
import asyncio
import json
import threading
import time
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
pytest.importorskip("a2a", reason="Install agent-framework[a2a] to run A2A tests")
from agent_framework.a2a.server import install_a2a, install_backend_a2a
from agent_framework.a2a.progress import report_progress
from agent_framework.global_supervisor.models import BackendDefinition
HEADERS = {'A2A-Version': '1.0'}
def send(client, method, params, legacy=False):
return client.post('/a2a', headers={'A2A-Version': '0.3' if legacy else '1.0'}, json={
'jsonrpc': '2.0', 'id': 'request', 'method': method, 'params': params,
})
def message_params(legacy=False, immediate=True, task_id=None):
message = {'messageId': 'm-' + str(time.monotonic_ns()), 'contextId': 'session',
'role': 'user' if legacy else 'ROLE_USER', 'parts': [{'text': 'Olá'}]}
if task_id:
message['taskId'] = task_id
if legacy:
message['kind'] = 'message'
message['parts'][0]['kind'] = 'text'
return {'message': message,
'configuration': {'blocking': not immediate} if legacy else {'returnImmediately': immediate}}
def task_result(response, legacy=False):
data = response.json()
assert 'error' not in data, data
return data['result'] if legacy else data['result']['task']
def get_task(client, task_id, legacy=False):
data = send(client, 'tasks/get' if legacy else 'GetTask', {'id': task_id}, legacy).json()
assert 'error' not in data, data
return data['result']
def wait_state(client, task_id, expected, legacy=False):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
task = get_task(client, task_id, legacy)
if task['status']['state'] == expected:
return task
time.sleep(.005)
pytest.fail(f'Task did not reach {expected}: {task}')
def application(handler, **kwargs):
app = FastAPI()
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='native', url='http://native')])
install_a2a(app, handler, registry, 'http://testserver', **kwargs)
return app
@pytest.mark.parametrize('legacy', [False, True])
def test_submit_returns_before_processing_and_poll_after_disconnect(legacy):
release = threading.Event()
async def handler(request):
while not release.is_set():
await asyncio.sleep(.005)
return {'text': 'done', 'session_id': request['session_id']}
with TestClient(application(handler)) as client:
try:
first = task_result(send(client, 'message/send' if legacy else 'SendMessage', message_params(legacy), legacy), legacy)
assert not release.is_set()
assert first['status']['state'] in ({'submitted', 'working'} if legacy else {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'})
assert get_task(client, first['id'], legacy)['contextId'] == 'session'
release.set()
task = wait_state(client, first['id'], 'completed' if legacy else 'TASK_STATE_COMPLETED', legacy)
assert task['artifacts'][0]['parts'][0]['text'] == 'done'
finally:
release.set()
@pytest.mark.parametrize('legacy', [False, True])
def test_stream_contains_task_progress_artifact_and_terminal_state(legacy):
async def handler(request):
await report_progress('workflow.started', {'route': 'orders'})
await asyncio.sleep(.01)
await report_progress('workflow.completed', {'route': 'orders'})
return {'text': 'done'}
with TestClient(application(handler)) as client:
assert client.get('/.well-known/agent-card.json').json()['capabilities']['streaming']
response = send(client, 'message/stream' if legacy else 'SendStreamingMessage', message_params(legacy, immediate=False), legacy)
assert 'text/event-stream' in response.headers['content-type'], response.text
events = [json.loads(line[5:].strip()) for line in response.text.splitlines() if line.startswith('data:')]
assert events and all('error' not in e for e in events), events
text = json.dumps(events)
assert 'workflow.started' in text and 'workflow.completed' in text
assert 'done' in text
assert ('completed' if legacy else 'TASK_STATE_COMPLETED') in text
first = events[0]['result']
assert ('id' in first if legacy else 'task' in first), events
def test_cancel_stops_local_execution():
started, stopped = threading.Event(), threading.Event()
async def handler(request):
started.set()
try:
await asyncio.sleep(30)
finally:
stopped.set()
with TestClient(application(handler)) as client:
first = task_result(send(client, 'SendMessage', message_params()))
assert started.wait(1)
result = send(client, 'CancelTask', {'id': first['id']}).json()
assert 'error' not in result, result
assert result['result']['status']['state'] == 'TASK_STATE_CANCELED'
assert stopped.wait(1)
assert get_task(client, first['id'])['status']['state'] == 'TASK_STATE_CANCELED'
@pytest.mark.parametrize('failure', ['exception', 'timeout'])
def test_failure_is_persisted(failure):
async def handler(request):
if failure == 'exception':
raise RuntimeError('internal-sensitive-detail')
await asyncio.sleep(1)
with TestClient(application(handler, timeout_seconds=.02)) as client:
first = task_result(send(client, 'SendMessage', message_params()))
task = wait_state(client, first['id'], 'TASK_STATE_FAILED')
assert 'internal-sensitive-detail' not in json.dumps(task)
assert task['status']['message']['parts'][0]['text'] == ('Processing timed out' if failure == 'timeout' else 'Agent processing failed')
def test_input_required_resumes_same_task():
turns = []
async def handler(request):
turns.append(request)
return {'text': 'Confirmar?' if len(turns) == 1 else 'Concluído',
'metadata': {'a2a_task_state': 'input-required'} if len(turns) == 1 else {}}
with TestClient(application(handler)) as client:
first = task_result(send(client, 'SendMessage', message_params(immediate=False)))
assert first['status']['state'] == 'TASK_STATE_INPUT_REQUIRED'
second = task_result(send(client, 'SendMessage', message_params(immediate=False, task_id=first['id'])))
assert second['id'] == first['id']
assert second['status']['state'] == 'TASK_STATE_COMPLETED'
assert len(turns) == 2
# A completed task cannot execute a third business operation.
response = send(client, 'SendMessage', message_params(task_id=first['id'])).json()
assert 'error' in response
assert len(turns) == 2
def test_backend_installer_exposes_agent_skills():
seen = []
async def handler(request):
seen.append(request)
return {'text': 'native agent', 'session_id': request['session_id']}
profiles = SimpleNamespace(list_profiles=lambda: [SimpleNamespace(agent_id='orders', name='Orders', description='Order status')])
settings = SimpleNamespace(A2A_PUBLIC_URL='https://backend.example', A2A_SECURITY_SCHEMES={}, A2A_SECURITY=[],
A2A_TASK_MODE=True, A2A_TASK_TIMEOUT_SECONDS=10, APP_NAME='Framework backend')
app = FastAPI()
install_backend_a2a(app, handler, profiles, settings)
with TestClient(app) as client:
card = client.get('/.well-known/agent-card.json').json()
assert card['skills'][0]['id'] == 'orders'
assert card['supportedInterfaces'][0]['url'] == 'https://backend.example/a2a'
params = message_params(immediate=False)
params['message']['metadata'] = {'agent_id': 'orders', 'tenant_id': 't'}
final = task_result(send(client, 'SendMessage', params))
assert final['status']['state'] == 'TASK_STATE_COMPLETED'
assert seen[0]['agent_id'] == 'orders' and seen[0]['tenant_id'] == 't'
def test_tasks_are_scoped_to_authenticated_principal():
from agent_framework.security.middleware import AuthenticationMiddleware
from agent_framework.security.authentication import AuthenticationResult, AuthenticatedPrincipal
class TestProvider:
async def authenticate(self, request):
# Test-only identity provider, equivalent to verified middleware output.
return AuthenticationResult(True, AuthenticatedPrincipal(request.headers['x-test-principal'], 'test'))
async def handler(request):
return {'text': 'private result'}
app = application(handler)
app.add_middleware(AuthenticationMiddleware, provider=TestProvider())
with TestClient(app) as client:
client.headers['x-test-principal'] = 'alice'
first = task_result(send(client, 'SendMessage', message_params(immediate=False)))
assert get_task(client, first['id'])['artifacts'][0]['parts'][0]['text'] == 'private result'
client.headers['x-test-principal'] = 'bob'
for method in ['GetTask', 'CancelTask']:
result = send(client, method, {'id': first['id']}).json()
assert 'error' in result
assert 'private result' not in json.dumps(result)
listing = send(client, 'ListTasks', {}).json()
assert not listing['result'].get('tasks'), listing
@pytest.mark.parametrize('legacy', [False, True])
def test_gateway_to_framework_a2a_backend(monkeypatch, legacy):
import httpx
from agent_framework.global_supervisor.client import BackendClient
from agent_framework.global_supervisor.models import GlobalRouteDecision
backend_release = threading.Event()
async def backend_handler(request):
while not backend_release.is_set():
await asyncio.sleep(.005)
return {'text': 'backend complete', 'session_id': request['session_id']}
backend_app = application(backend_handler)
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: real_client(transport=httpx.ASGITransport(app=backend_app), **kw))
backend_client = BackendClient(timeout_seconds=2)
definition = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a',
a2a_version='0.3' if legacy else '1.0', a2a_poll_interval_seconds=.005)
async def gateway_handler(request):
result = await backend_client.call_message(definition, request, GlobalRouteDecision(backend_id='x'))
return result.response
with TestClient(application(gateway_handler)) as client:
try:
first = task_result(send(client, 'SendMessage', message_params()))
assert first['status']['state'] in {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'}
assert not backend_release.is_set()
backend_release.set()
final = wait_state(client, first['id'], 'TASK_STATE_COMPLETED')
assert final['artifacts'][0]['parts'][0]['text'] == 'backend complete'
finally:
backend_release.set()
def test_template_progress_helper_preserves_native_event_contract():
# Exercise the actual lightweight helper without booting OCI/LLM dependencies.
import ast
from pathlib import Path
source = Path(__file__).resolve().parents[1] / 'templates/agent_template_backend/app/main.py'
module = ast.parse(source.read_text())
helper = next(n for n in module.body if isinstance(n, ast.AsyncFunctionDef) and n.name == '_emit_gateway_event')
native, a2a = [], []
async def native_emit(*args):
native.append(args)
async def progress(*args):
a2a.append(args)
namespace = {'sse_hub': SimpleNamespace(emit=native_emit)}
exec(compile(ast.Module(body=[helper], type_ignores=[]), str(source), 'exec'), namespace)
emit = namespace['_emit_gateway_event']
async def scenario():
await emit('session', 'flow.start', {'x': 1}, True, None)
await emit('session', 'workflow.started', {'x': 2}, False, progress)
await emit('session', 'message.responded', {'x': 3}, False, None)
asyncio.run(scenario())
assert native == [('session', 'flow.start', {'x': 1})]
assert a2a == [('workflow.started', {'x': 2})]

View File

@@ -0,0 +1,81 @@
"""Verify SSE flush and disconnect/reconnect over a real local HTTP connection."""
import asyncio
import json
import socket
import threading
import time
from types import SimpleNamespace
import httpx
import pytest
import uvicorn
from fastapi import FastAPI
pytest.importorskip("a2a", reason="Install agent-framework[a2a] to run A2A tests")
from agent_framework.a2a.server import install_a2a
from agent_framework.global_supervisor.models import BackendDefinition
@pytest.mark.parametrize('legacy', [False, True])
def test_sse_flush_disconnect_and_resubscribe(legacy):
release = threading.Event()
finished = threading.Event()
async def handler(request):
while not release.is_set():
await asyncio.sleep(.005)
finished.set()
return {'text': 'stream complete'}
app = FastAPI()
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='x', url='http://native')])
install_a2a(app, handler, registry, 'http://testserver')
sock = socket.socket()
sock.bind(('127.0.0.1', 0))
server = uvicorn.Server(uvicorn.Config(app, log_level='error', lifespan='off'))
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
thread.start()
try:
deadline = time.monotonic() + 3
while not server.started and time.monotonic() < deadline:
time.sleep(.005)
assert server.started
base_url = f'http://127.0.0.1:{sock.getsockname()[1]}'
with httpx.Client(base_url=base_url, headers={'A2A-Version': '0.3' if legacy else '1.0'}, timeout=2, trust_env=False) as client:
message = {'messageId': 'm', 'role': 'user' if legacy else 'ROLE_USER',
'parts': [{'kind': 'text', 'text': 'run'}] if legacy else [{'text': 'run'}]}
if legacy: message['kind'] = 'message'
def envelope(method, params):
return {'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}
with client.stream('POST', '/a2a', json=envelope('message/stream' if legacy else 'SendStreamingMessage', {'message': message})) as response:
assert response.status_code == 200
for line in response.iter_lines():
if line.startswith('data:'):
event = json.loads(line[5:].strip())
first = event['result'] if legacy else event['result']['task']
task_id = first['id']
break
else:
pytest.fail('SSE did not deliver an initial Task')
assert not release.is_set() and not finished.is_set()
# Closing the first SSE connection must not cancel the agent task.
current = client.post('/a2a', json=envelope('tasks/get' if legacy else 'GetTask', {'id': task_id})).json()
assert current['result']['status']['state'] in ({'submitted', 'working'} if legacy else {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'})
with client.stream('POST', '/a2a', json=envelope('tasks/resubscribe' if legacy else 'SubscribeToTask', {'id': task_id})) as response:
events = []
for line in response.iter_lines():
if not line.startswith('data:'):
continue
event = json.loads(line[5:].strip())
assert 'error' not in event, event
events.append(event)
if len(events) == 1:
release.set()
encoded = json.dumps(events)
assert 'stream complete' in encoded
assert ('completed' if legacy else 'TASK_STATE_COMPLETED') in encoded
assert finished.is_set()
finally:
release.set()
server.should_exit = True
thread.join(timeout=3)
sock.close()
assert not thread.is_alive()

View File

@@ -0,0 +1,188 @@
import asyncio
import json
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
pytest.importorskip("a2a", reason="Install agent-framework[a2a] to run A2A tests")
from agent_framework.a2a.server import install_a2a
from agent_framework.global_supervisor.models import BackendDefinition, GlobalRouteDecision
from agent_framework.global_supervisor.client import BackendClient
@pytest.fixture
def facade():
seen = []
async def handler(request):
seen.append(request)
return {'channel': 'web', 'session_id': request['session_id'], 'text': 'Olá', 'metadata': {}}
app = FastAPI()
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='native', url='http://native')])
install_a2a(app, handler, registry, 'https://agents.example', task_mode=False)
with TestClient(app) as client:
yield client, seen
@pytest.mark.parametrize('legacy', [False, True])
def test_discovery_and_send(facade, legacy):
client, seen = facade
card = client.get('/.well-known/agent-card.json').json()
assert card['supportedInterfaces'][0]['protocolVersion'] == '1.0'
message = {'messageId': 'm', 'role': 'ROLE_USER', 'contextId': 'conversation',
'parts': [{'text': 'Oi'}, {'data': {'invoice_id': 42, 'session_id': 'override'}}]}
if legacy:
message.update(kind='message', role='user')
for part in message['parts']: part['kind'] = 'text' if 'text' in part else 'data'
response = client.post('/a2a', headers={'A2A-Version': '0.3' if legacy else '1.0'}, json={
'jsonrpc': '2.0', 'id': 'x', 'method': 'message/send' if legacy else 'SendMessage',
'params': {'message': message},
}).json()
reply = response['result'] if legacy else response['result']['message']
assert reply['parts'][0]['text'] == 'Olá', response
assert reply['contextId'] == 'conversation'
assert seen[0]['payload']['invoice_id'] == 42
assert seen[0]['payload']['session_id'] == 'conversation'
def test_errors(facade):
client, _ = facade
for method, params in [('Unknown', {}), ('GetTask', {'id': 'missing'})]:
response = client.post('/a2a', headers={'A2A-Version': '1.0'}, json={
'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params,
}).json()
assert 'error' in response
def mock_http(monkeypatch, handler):
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
@pytest.mark.parametrize('protocol', ['native', '1.0', '0.3'])
def test_backend_adapter(monkeypatch, protocol):
seen = []
def transport(request):
body = json.loads(request.content)
seen.append((request, body))
if protocol == 'native': return httpx.Response(200, json={'text': 'OK', 'session_id': 's'})
reply = {'messageId': 'r', 'role': 'ROLE_AGENT', 'contextId': 'remote', 'parts': [{'text': 'OK'}]}
if protocol == '0.3':
reply.update(kind='message', role='agent'); reply['parts'][0]['kind'] = 'text'
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': reply if protocol == '0.3' else {'message': reply}})
mock_http(monkeypatch, transport)
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='native' if protocol == 'native' else 'a2a', a2a_version='0.3' if protocol == '0.3' else '1.0')
response = asyncio.run(BackendClient().call_message(backend, {'payload': {'session_id': 's', 'message': 'Oi'}}, GlobalRouteDecision(backend_id='x')))
assert response.response['text'] == 'OK'
assert response.response['session_id'] == 's'
if protocol == 'native':
assert str(seen[0][0].url) == 'http://backend/gateway/message'
assert seen[0][1]['payload']['selected_backend'] == 'x'
else:
assert seen[0][1]['method'] == ('message/send' if protocol == '0.3' else 'SendMessage')
def test_task_polling(monkeypatch):
methods = []
def transport(request):
body = json.loads(request.content); methods.append(body['method'])
task = {'id': 'task', 'contextId': 'remote', 'status': {'state': 'TASK_STATE_WORKING' if len(methods) == 1 else 'TASK_STATE_COMPLETED'}}
if len(methods) == 2: task['artifacts'] = [{'artifactId': 'a', 'parts': [{'text': 'done'}]}]
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': {'task': task} if len(methods) == 1 else task})
mock_http(monkeypatch, transport)
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_poll_interval_seconds=.001)
response = asyncio.run(BackendClient().call_message(backend, {'payload': {'message': 'Oi'}}, GlobalRouteDecision(backend_id='x')))
assert response.response['text'] == 'done'
assert methods == ['SendMessage', 'GetTask']
def test_rpc_failure(monkeypatch):
def transport(request):
body = json.loads(request.content)
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'error': {'code': -32603, 'message': 'private'}})
mock_http(monkeypatch, transport)
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a')
with pytest.raises(RuntimeError, match='-32603'):
asyncio.run(BackendClient().call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
def test_continuation_and_tenant_isolation(monkeypatch):
seen = []
def transport(request):
body = json.loads(request.content); seen.append(body['params']['message'])
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': {'task': {
'id': 'remote-task', 'contextId': 'remote-context', 'status': {'state': 'TASK_STATE_INPUT_REQUIRED',
'message': {'messageId': 'a', 'role': 'ROLE_AGENT', 'parts': [{'text': 'Confirmar?'}]}}
}}})
mock_http(monkeypatch, transport)
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a')
async def scenario():
client = BackendClient()
for tenant in ['first', 'first', 'second']:
await client.call_message(backend, {'tenant_id': tenant, 'payload': {'session_id': 'same', 'message': 'sim'}}, GlobalRouteDecision(backend_id='x'))
asyncio.run(scenario())
assert seen[1]['taskId'] == 'remote-task'
assert seen[1]['contextId'] == 'remote-context'
assert 'taskId' not in seen[2]
assert seen[0]['contextId'] != seen[2]['contextId']
def test_credentials_and_deadline(monkeypatch):
monkeypatch.delenv('MISSING_A2A_TOKEN', raising=False)
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_headers_env={'Authorization': 'MISSING_A2A_TOKEN'})
with pytest.raises(ValueError, match='MISSING_A2A_TOKEN'):
asyncio.run(BackendClient().call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
async def transport(request):
await asyncio.sleep(1)
return httpx.Response(200)
mock_http(monkeypatch, transport)
backend.a2a_headers_env = {}
with pytest.raises(TimeoutError):
asyncio.run(BackendClient(timeout_seconds=.01).call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
def test_native_does_not_import_sdk():
import subprocess
import sys
code = '''
import sys
class BlockA2A:
def find_spec(self, fullname, *args):
if fullname == 'a2a' or fullname.startswith('a2a.'):
raise ImportError('A2A unavailable')
sys.meta_path.insert(0, BlockA2A())
from agent_framework.global_supervisor.client import BackendClient
from agent_framework.global_supervisor.models import BackendDefinition
assert BackendDefinition(backend_id='x', url='http://x').protocol == 'native'
BackendClient()
'''
subprocess.run([sys.executable, '-c', code], check=True)
def test_health_uses_card_and_credentials(monkeypatch):
seen = []
def transport(request):
seen.append(request)
return httpx.Response(200, json={'name': 'external'})
mock_http(monkeypatch, transport)
monkeypatch.setenv('A2A_TEST_AUTH', 'Bearer test')
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_headers_env={'Authorization':'A2A_TEST_AUTH'})
result = asyncio.run(BackendClient().health(backend))
assert result['ok']
assert seen[0].url.path == '/.well-known/agent-card.json'
assert seen[0].headers['Authorization'] == 'Bearer test'
def test_a2a_backend_selection_is_forwarded_to_gateway(facade):
client, seen = facade
response = client.post('/a2a', headers={'A2A-Version': '1.0'}, json={
'jsonrpc': '2.0', 'id': 'select', 'method': 'SendMessage', 'params': {
'message': {'messageId': 'm', 'role': 'ROLE_USER', 'parts': [{'text': 'Olá'}],
'metadata': {'backend_id': 'billing', 'agent_id': 'billing_agent'}},
},
}).json()
assert 'result' in response, response
assert seen[0]['backend_id'] == 'billing'
assert seen[0]['agent_id'] == 'billing_agent'