mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 11:24:13 +00:00
A2A Protocol
This commit is contained in:
3
apps/agent_frontend_a2a/Dockerfile
Normal file
3
apps/agent_frontend_a2a/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:stable-alpine
|
||||
COPY index.html styles.css app.js a2a-client.js /usr/share/nginx/html/
|
||||
EXPOSE 80
|
||||
133
apps/agent_frontend_a2a/README.md
Normal file
133
apps/agent_frontend_a2a/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Frontend A2A para o Agent Framework OCI
|
||||
|
||||
Interface estática, sem build e sem dependências JavaScript de produção. Usa os
|
||||
endpoints A2A que já existem no pacote: pode falar com o template de backend
|
||||
ou com o Agent Gateway. O frontend nativo em `apps/agent_frontend` é preservado.
|
||||
|
||||
## Experimentar sem OCI
|
||||
|
||||
Na raiz do framework:
|
||||
|
||||
```sh
|
||||
python -m pip install -e 'libs/agent_framework[a2a]' uvicorn
|
||||
python -m uvicorn examples.a2a_frontend_demo:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Abra **http://localhost:8000** e clique em **Conectar ao serviço**. O exemplo
|
||||
serve a tela e a API na mesma origem, sem precisar configurar CORS.
|
||||
|
||||
A demonstração usa o **adaptador A2A e o SDK reais**, com a lógica de negócio
|
||||
simulada. Digite uma mensagem e acompanhe a tarefa. Comandos de demonstração:
|
||||
|
||||
- `/confirmar`: aguarda sua resposta e retoma a mesma Task.
|
||||
- `/lento`: permite testar o botão de cancelamento.
|
||||
- `/erro`: demonstra uma Task que termina em falha.
|
||||
|
||||
Se a porta 8000 estiver ocupada, altere a porta ao iniciar e informe o novo
|
||||
endereço no campo **Endereço do serviço**, ou use `?service=http://localhost:NOVA_PORTA`.
|
||||
|
||||
## Usar com seu agente ou Gateway
|
||||
|
||||
Habilite A2A no serviço, conforme `docs/A2A_ASSINCRONO.md`, e sirva este diretório:
|
||||
|
||||
```sh
|
||||
python -m http.server 5173 --directory apps/agent_frontend_a2a
|
||||
```
|
||||
|
||||
Abra **http://localhost:5173**. Não abra `index.html` como arquivo local, pois a
|
||||
interface usa módulos JavaScript e requisições HTTP.
|
||||
|
||||
| Uso | Endereço do serviço | Destino na interface | Seleção carregada do Agent Card |
|
||||
| --- | --- | --- | --- |
|
||||
| Template de agente | `http://localhost:8000` | Agente | Perfis de `agents.yaml` |
|
||||
| Agent Gateway | `http://localhost:8010` | Gateway | Backends do catálogo |
|
||||
|
||||
Use **Seleção automática** para deixar a decisão com o serviço. No Gateway,
|
||||
um backend selecionado é enviado como `message.metadata.backend_id`. Um perfil
|
||||
opcional dentro dele pode ser informado em **Perfil do agente no backend**.
|
||||
O adaptador A2A encaminha a seleção ao roteador existente; os contratos nativos
|
||||
não são alterados.
|
||||
|
||||
O serviço deve permitir a origem da interface em `CORS_ORIGINS`. O valor padrão
|
||||
do pacote já permite `http://localhost:5173`. Se mudar o host ou a porta,
|
||||
configure a origem exata, por exemplo:
|
||||
|
||||
```dotenv
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
ENABLE_A2A=true
|
||||
A2A_TASK_MODE=true
|
||||
```
|
||||
|
||||
Para publicar com Docker:
|
||||
|
||||
```sh
|
||||
docker build -t agent-frontend-a2a apps/agent_frontend_a2a
|
||||
docker run --rm -p 5173:80 agent-frontend-a2a
|
||||
```
|
||||
|
||||
O navegador precisa alcançar o endereço informado no formulário. Para uso
|
||||
externo, informe a URL pública do backend/Gateway, não o nome interno do
|
||||
container. Uma interface HTTPS deve acessar um serviço HTTPS.
|
||||
|
||||
## O que a interface oferece
|
||||
|
||||
- Descoberta do Agent Card e escolha de versão automática ou explícita (1.0/0.3).
|
||||
- Envio de mensagem com retorno imediato de Task.
|
||||
- Acompanhamento por POST SSE com `SubscribeToTask`/`tasks/resubscribe`.
|
||||
- Recuperação por `GetTask`/`tasks/get` quando o fluxo termina ou cai.
|
||||
- Consulta periódica como alternativa ao streaming.
|
||||
- Cancelamento solicitado ao serviço, incluindo tarefas aguardando input.
|
||||
- Continuação de tarefas em input-required/auth-required.
|
||||
- Exibição de respostas diretas e dos artefatos de Tasks, com suporte aos
|
||||
resultados estruturados de múltiplos agentes do framework.
|
||||
- Contexto de negócio opcional (cliente e contrato/pedido).
|
||||
- Identificadores da conversa/tarefa preservados em `sessionStorage` para
|
||||
retomar após recarregar a mesma aba. O conteúdo do chat não é persistido.
|
||||
- Nova conversa independente, sem reaproveitar uma Task concluída.
|
||||
|
||||
O frontend usa `fetch` para SSE, permitindo enviar os mesmos cabeçalhos de
|
||||
autenticação nas consultas e no acompanhamento. Em **Contexto e autenticação**,
|
||||
informe o nome e o valor completo do cabeçalho exigido pelo serviço, por
|
||||
exemplo `Authorization` / `Bearer ...`, ou `x-api-key` / valor da chave.
|
||||
Credenciais ficam somente na memória e precisam ser informadas novamente após
|
||||
recarregar. Não são gravadas em `sessionStorage` nem colocadas na URL.
|
||||
|
||||
Nenhuma operação de envio é repetida automaticamente. Se a resposta do envio
|
||||
não chegar, a tela informa a incerteza, pois o serviço pode ter aceitado a
|
||||
mensagem. O botão **Retomar** consulta a mesma Task, quando seu ID é conhecido.
|
||||
Trocar conexão, tenant, perfil ou backend requer reconectar e inicia outra
|
||||
conversa quando a identidade da configuração mudou.
|
||||
|
||||
## Limites herdados do serviço
|
||||
|
||||
O frontend não transforma o armazenamento em memória em armazenamento durável.
|
||||
Uma Task perdida ao reiniciar o serviço não pode ser recuperada pela tela.
|
||||
O cancelamento local não desfaz efeitos externos já confirmados e não garante
|
||||
cancelar uma operação já aceita por um agente remoto.
|
||||
|
||||
`auth-required` indica que a tarefa aguarda autorização, mas esta interface não
|
||||
implementa login OAuth nem aprovação externa automática. Realize o fluxo exigido
|
||||
pelo agente, atualize a credencial se necessário e retome a tarefa.
|
||||
|
||||
Não há upload de arquivos, push notifications, gRPC ou emissão de tokens do LLM.
|
||||
A seleção de versão 0.3 é explícita quando o serviço publica um Agent Card 1.0
|
||||
mas também aceita o formato 0.3, como ocorre no adaptador do framework.
|
||||
|
||||
## Testes
|
||||
|
||||
Sem bibliotecas JavaScript adicionais, com Node.js 22 ou superior:
|
||||
|
||||
```sh
|
||||
cd apps/agent_frontend_a2a
|
||||
node --test tests/client.test.js
|
||||
```
|
||||
|
||||
Com a demonstração em execução:
|
||||
|
||||
```sh
|
||||
A2A_DEMO_URL=http://localhost:8000 node --test tests/*.test.js
|
||||
```
|
||||
|
||||
Os testes de integração exercitam o cliente JavaScript contra o SDK real do
|
||||
framework: A2A 1.0 e 0.3, envio, streaming, consulta, confirmação, cancelamento
|
||||
e falha. Os testes de interface também foram realizados em navegador local.
|
||||
167
apps/agent_frontend_a2a/a2a-client.js
Normal file
167
apps/agent_frontend_a2a/a2a-client.js
Normal file
@@ -0,0 +1,167 @@
|
||||
export const uid = () => globalThis.crypto.randomUUID();
|
||||
export const stateName = (state = '') => state.replace(/^TASK_STATE_/, '').toLowerCase().replaceAll('_', '-');
|
||||
export const isStopped = state => ['completed', 'failed', 'canceled', 'rejected', 'input-required', 'auth-required'].includes(stateName(state));
|
||||
export const needsInput = state => ['input-required', 'auth-required'].includes(stateName(state));
|
||||
|
||||
export class RpcError extends Error {
|
||||
constructor(error) { super(error.message || 'O serviço retornou um erro.'); this.name = 'RpcError'; this.code = error.code; }
|
||||
}
|
||||
export function serviceURLs(value) {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Informe uma URL HTTP ou HTTPS sem credenciais, parâmetros ou fragmentos.');
|
||||
const base = url.href.replace(/\/$/, '').replace(/\/a2a$/, '');
|
||||
return { base, rpc: `${base}/a2a`, card: `${base}/.well-known/agent-card.json` };
|
||||
}
|
||||
export function normalizeResult(result) {
|
||||
if (!result || typeof result !== 'object') throw new Error('Resposta A2A inválida.');
|
||||
for (const [key, type] of [['task','task'], ['message','message'], ['statusUpdate','status'], ['artifactUpdate','artifact']]) {
|
||||
if (result[key]) return { type, value: result[key] };
|
||||
}
|
||||
const kinds = { task: 'task', message: 'message', 'status-update': 'status', 'artifact-update': 'artifact' };
|
||||
if (kinds[result.kind]) return { type: kinds[result.kind], value: result };
|
||||
throw new Error('O serviço não retornou uma mensagem, tarefa ou evento A2A reconhecido.');
|
||||
}
|
||||
export function selectVersion(card, preference = 'auto') {
|
||||
if (preference !== 'auto') return preference;
|
||||
const versions = (card.supportedInterfaces || []).filter(i => i.protocolBinding === 'JSONRPC').map(i => i.protocolVersion);
|
||||
if (versions.some(v => /^1\./.test(v))) return '1.0';
|
||||
if (/^0\.3/.test(card.protocolVersion || '') || versions.some(v => /^0\.3/.test(v))) return '0.3';
|
||||
throw new Error('O Agent Card não anuncia A2A 1.0 ou 0.3 por JSON-RPC.');
|
||||
}
|
||||
|
||||
// SSE over POST/fetch supports authentication headers and both A2A versions.
|
||||
export async function* parseSSE(body) {
|
||||
if (!body) throw new Error('O serviço não abriu o fluxo de eventos.');
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', data = [], size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const end = buffer.search(/[\r\n]/);
|
||||
if (end < 0 || (!done && buffer[end] === '\r' && end === buffer.length - 1)) break;
|
||||
const line = buffer.slice(0, end);
|
||||
buffer = buffer.slice(end + (buffer.slice(end, end + 2) === '\r\n' ? 2 : 1));
|
||||
if (!line) {
|
||||
if (data.length) yield data.join('\n');
|
||||
data = []; size = 0;
|
||||
} else if (line.startsWith('data:')) {
|
||||
const entry = line.slice(5).replace(/^ /, '');
|
||||
data.push(entry); size += entry.length;
|
||||
}
|
||||
if (size > 4 * 1024 * 1024) throw new Error('Evento recebido excedeu o limite de tamanho.');
|
||||
}
|
||||
if (buffer.length > 4 * 1024 * 1024) throw new Error('Evento recebido excedeu o limite de tamanho.');
|
||||
if (done) break; // A partial frame is not an event; the caller can recover with GetTask.
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export class A2AClient {
|
||||
constructor({ url, version = '1.0', headers = {}, fetchImpl = globalThis.fetch }) {
|
||||
this.urls = serviceURLs(url); this.version = version; this.fetch = fetchImpl.bind(globalThis);
|
||||
this.headers = { ...headers, 'A2A-Version': version };
|
||||
}
|
||||
method(v1, v03) { return this.version === '0.3' ? v03 : v1; }
|
||||
async discover(signal) {
|
||||
const response = await this.fetch(this.urls.card, { headers: this.headers, credentials: 'omit', signal });
|
||||
if (!response.ok) throw new Error(`Não foi possível carregar o Agent Card (HTTP ${response.status}).`);
|
||||
return response.json();
|
||||
}
|
||||
async rpc(method, params, { signal, timeoutMs = 15000 } = {}) {
|
||||
const id = uid();
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const response = await this.fetch(this.urls.rpc, {
|
||||
method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, credentials: 'omit',
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
if (!response.ok) throw new Error(`O serviço retornou HTTP ${response.status}. Confira o endereço e a autenticação.`);
|
||||
const data = await response.json();
|
||||
return this.validate(data, id);
|
||||
}
|
||||
validate(data, id) {
|
||||
if (data.jsonrpc !== '2.0' || data.id !== id) throw new Error('Resposta JSON-RPC inválida.');
|
||||
if (data.error) throw new RpcError(data.error);
|
||||
if (!Object.hasOwn(data, 'result')) throw new Error('Resposta sem resultado A2A.');
|
||||
return data.result;
|
||||
}
|
||||
async sendMessage(text, { contextId, taskId, tenantId, agentId, backendId, data = {}, signal } = {}) {
|
||||
const legacy = this.version === '0.3';
|
||||
const message = {
|
||||
messageId: uid(), role: legacy ? 'user' : 'ROLE_USER', contextId,
|
||||
parts: [{ text }, { data }], metadata: {},
|
||||
};
|
||||
if (taskId) message.taskId = taskId;
|
||||
for (const [key, value] of Object.entries({ tenant_id: tenantId, agent_id: agentId, backend_id: backendId })) {
|
||||
if (value) message.metadata[key] = value;
|
||||
}
|
||||
if (legacy) {
|
||||
message.kind = 'message'; message.parts[0].kind = 'text'; message.parts[1].kind = 'data';
|
||||
}
|
||||
const result = await this.rpc(this.method('SendMessage', 'message/send'), {
|
||||
message, configuration: legacy ? { blocking: false } : { returnImmediately: true },
|
||||
}, { signal });
|
||||
return normalizeResult(result);
|
||||
}
|
||||
async getTask(id, signal) { return this.rpc(this.method('GetTask', 'tasks/get'), { id }, { signal }); }
|
||||
async cancelTask(id) { return this.rpc(this.method('CancelTask', 'tasks/cancel'), { id }); }
|
||||
async *subscribe(id, signal) {
|
||||
const requestId = uid();
|
||||
const response = await this.fetch(this.urls.rpc, {
|
||||
method: 'POST', credentials: 'omit', signal,
|
||||
headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method: this.method('SubscribeToTask', 'tasks/resubscribe'), params: { id } }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Não foi possível acompanhar os eventos (HTTP ${response.status}).`);
|
||||
if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
|
||||
this.validate(await response.json(), requestId);
|
||||
throw new Error('O serviço não retornou um fluxo SSE.');
|
||||
}
|
||||
for await (const frame of parseSSE(response.body)) {
|
||||
yield normalizeResult(this.validate(JSON.parse(frame), requestId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskView {
|
||||
constructor(task = {}) { this.task = structuredClone(task); this.task.artifacts ||= []; }
|
||||
apply(event) {
|
||||
const value = event.value;
|
||||
if (event.type === 'task') {
|
||||
if (this.task.id && value.id !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
this.task = structuredClone(value); this.task.artifacts ||= [];
|
||||
}
|
||||
else if (event.type === 'status') {
|
||||
if (this.task.id && value.taskId !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
this.task.id = value.taskId; this.task.contextId = value.contextId; this.task.status = value.status;
|
||||
} else if (event.type === 'artifact') {
|
||||
if (this.task.id && value.taskId !== this.task.id) throw new Error('Evento de outra tarefa.');
|
||||
const index = this.task.artifacts.findIndex(a => a.artifactId === value.artifact.artifactId);
|
||||
if (index < 0) this.task.artifacts.push(structuredClone(value.artifact));
|
||||
else if (value.append) this.task.artifacts[index].parts.push(...structuredClone(value.artifact.parts || []));
|
||||
else this.task.artifacts[index] = structuredClone(value.artifact);
|
||||
}
|
||||
return this.task;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayResponse(value, depth = 0) {
|
||||
if (!value || depth > 5) return null;
|
||||
const task = value.task || value;
|
||||
const parts = task.artifacts?.at(-1)?.parts || task.status?.message?.parts || value.message?.parts || value.parts || [];
|
||||
const native = parts.find(p => p.data && typeof p.data === 'object' && ('text' in p.data || 'speak' in p.data || 'metadata' in p.data))?.data;
|
||||
if (native) {
|
||||
const remote = native.metadata?.a2a_result;
|
||||
return (remote && displayResponse(remote, depth + 1)) || native;
|
||||
}
|
||||
const text = parts.filter(p => typeof p.text === 'string').map(p => p.text).join('');
|
||||
if (text) return { text };
|
||||
const structured = parts.filter(p => 'data' in p).map(p => p.data);
|
||||
return structured.length ? { text: JSON.stringify(structured.length === 1 ? structured[0] : structured, null, 2) } : null;
|
||||
}
|
||||
225
apps/agent_frontend_a2a/app.js
Normal file
225
apps/agent_frontend_a2a/app.js
Normal file
@@ -0,0 +1,225 @@
|
||||
import { A2AClient, TaskView, displayResponse, isStopped, needsInput, selectVersion, stateName, uid } from './a2a-client.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const value = id => $(id).value.trim();
|
||||
const labels = { submitted:'Tarefa recebida', working:'Agente trabalhando', completed:'Concluída', failed:'Falha no processamento', canceled:'Tarefa cancelada', rejected:'Solicitação recusada', 'input-required':'Aguardando sua resposta', 'auth-required':'Aguardando autorização' };
|
||||
const eventLabels = { 'flow.start':'Fluxo iniciado', 'session.upserted':'Sessão preparada', 'message.received':'Mensagem recebida', 'workflow.started':'Workflow iniciado', 'workflow.completed':'Workflow concluído', 'message.responded':'Resposta disponível', 'flow.end':'Fluxo finalizado', 'a2a.backend.status':'Status do backend atualizado' };
|
||||
const state = { client:null, card:null, contextId:uid(), view:new TaskView(), connected:false, busy:false, epoch:0, controller:null, turn:null, events:0, lastState:'', scope:null };
|
||||
const savedKey = 'framework-a2a-session-v1';
|
||||
const connectionFields = ['serviceUrl','target','version','skill','tenant','tracking','customerKey','contractKey','agentOverride','authHeader','authValue'];
|
||||
const messageGroups = new Map();
|
||||
|
||||
function notice(text = '') { $('notice').textContent = text; $('notice').hidden = !text; }
|
||||
function setStatus(text, kind = '') { $('taskStatus').textContent = text; $('taskDot').className = `dot ${kind}`; }
|
||||
function setBusy(busy) { state.busy = busy; document.body.setAttribute('aria-busy', String(busy)); controls(); }
|
||||
function controls() {
|
||||
const active = state.view.task.id && !isStopped(state.view.task.status?.state);
|
||||
const allowSend = state.connected && !state.busy && !active;
|
||||
$('message').disabled = !allowSend; $('sendButton').disabled = !allowSend;
|
||||
$('cancelButton').disabled = !state.connected || !(active || needsInput(state.view.task.status?.state));
|
||||
$('resumeButton').disabled = !state.connected || state.busy || !state.view.task.id;
|
||||
$('newConversation').disabled = state.busy;
|
||||
$('connectButton').disabled = state.busy;
|
||||
for (const id of connectionFields) $(id).disabled = state.busy;
|
||||
}
|
||||
function log(text) {
|
||||
const item = document.createElement('li');
|
||||
const time = document.createElement('time'); time.textContent = new Date().toLocaleTimeString('pt-BR');
|
||||
item.append(time, document.createTextNode(text)); $('activity').append(item);
|
||||
while ($('activity').children.length > 80) $('activity').firstChild.remove();
|
||||
$('eventCount').textContent = `${++state.events} eventos`;
|
||||
}
|
||||
function saveSession() {
|
||||
// Only identifiers and connection settings: no credentials or message content.
|
||||
try { sessionStorage.setItem(savedKey, JSON.stringify({
|
||||
serviceUrl: state.client?.urls.base, version:state.client?.version, target:value('target'),
|
||||
contextId:state.contextId, taskId:state.view.task.id, taskState:state.view.task.status?.state,
|
||||
tenant:value('tenant'), skill:value('skill'), agentOverride:value('agentOverride'),
|
||||
})); } catch { /* Storage can be unavailable in private browsing. */ }
|
||||
}
|
||||
function ids() {
|
||||
$('taskId').textContent = state.view.task.id || '—'; $('taskShort').textContent = state.view.task.id ? `#${state.view.task.id.slice(0,8)}` : '';
|
||||
$('contextId').textContent = state.contextId; $('contextShort').textContent = `Conversa ${state.contextId.slice(0,8)}`;
|
||||
}
|
||||
function stopWatcher() { ++state.epoch; state.controller?.abort(); state.controller = null; }
|
||||
function newConversation() {
|
||||
stopWatcher(); state.contextId = uid(); state.view = new TaskView(); state.turn = null; state.lastState = ''; messageGroups.clear();
|
||||
$('chat').replaceChildren(); $('activity').replaceChildren(); state.events = 0; $('eventCount').textContent='0 eventos'; notice();
|
||||
setBusy(false); ids(); setStatus(state.connected ? 'Pronto para conversar' : 'Pronto para conectar'); saveSession(); $('message').focus();
|
||||
}
|
||||
function userMessage(text) {
|
||||
const div = document.createElement('div'); div.className='message user'; div.textContent=text;
|
||||
$('welcome')?.remove(); $('chat').append(div); scrollChat();
|
||||
}
|
||||
function agentMessages(response) {
|
||||
const metadata = response.metadata || {};
|
||||
const candidates = response.supervisor_results || metadata.supervisor_results || response.agent_responses || metadata.agent_responses;
|
||||
if (Array.isArray(candidates)) {
|
||||
const parts = candidates.map(p => ({ agent:p.agent || p.agent_id || p.name || 'Agente', text:p.answer ?? p.text ?? p.message ?? p.response ?? p.content, primary:p.primary }));
|
||||
const valid = parts.filter(p => p.text != null && String(p.text).trim());
|
||||
if (valid.length) return valid.sort((a,b)=>Number(Boolean(a.primary))-Number(Boolean(b.primary)));
|
||||
}
|
||||
const text = response.text ?? response.speak ?? response.content ?? '';
|
||||
return [{ agent:metadata.active_agent || metadata.agent_id || state.card?.name || 'Agente', text }];
|
||||
}
|
||||
function scrollChat() { $('chat').scrollTop = $('chat').scrollHeight; }
|
||||
function renderResponse(response) {
|
||||
if (!response) return;
|
||||
$('welcome')?.remove(); state.turn ||= uid();
|
||||
let group = messageGroups.get(state.turn);
|
||||
if (!group) { group=document.createElement('section'); group.className='message agent'; group.setAttribute('aria-label','Resposta do agente'); messageGroups.set(state.turn,group); $('chat').append(group); }
|
||||
const signature=JSON.stringify(response); if (group.dataset.signature === signature) return;
|
||||
group.dataset.signature=signature; group.replaceChildren();
|
||||
for (const part of agentMessages(response)) {
|
||||
const section=document.createElement('section'); section.className='agent-section';
|
||||
const header=document.createElement('div'); header.className='agent-header';
|
||||
const avatar=document.createElement('span'); avatar.className='agent-avatar'; avatar.textContent='✳'; avatar.setAttribute('aria-hidden','true');
|
||||
const name=document.createElement('span'); name.textContent=String(part.agent).replaceAll('_',' ');
|
||||
const body=document.createElement('div'); body.className='agent-body'; body.textContent=typeof part.text==='string' ? part.text : JSON.stringify(part.text,null,2);
|
||||
header.append(avatar,name); section.append(header,body); group.append(section);
|
||||
}
|
||||
scrollChat();
|
||||
}
|
||||
function updateTask(event) {
|
||||
const task=state.view.apply(event);
|
||||
if (task.contextId) state.contextId=task.contextId;
|
||||
const current=stateName(task.status?.state);
|
||||
if (current && current!==state.lastState) { log(labels[current] || current); state.lastState=current; }
|
||||
if (event.type==='status' && event.value.metadata?.framework_event) log(eventLabels[event.value.metadata.framework_event] || event.value.metadata.framework_event);
|
||||
setStatus(labels[current] || 'Acompanhando tarefa', ['failed','rejected'].includes(current) ? 'error' : isStopped(current) ? '' : 'busy');
|
||||
if (event.type==='artifact' || isStopped(current)) renderResponse(displayResponse(task));
|
||||
ids(); saveSession(); controls();
|
||||
}
|
||||
function sleep(ms,signal) {
|
||||
return new Promise((resolve,reject)=>{
|
||||
if (signal.aborted) { reject(new DOMException('Interrompido','AbortError')); return; }
|
||||
const abort=()=>{clearTimeout(timer);reject(new DOMException('Interrompido','AbortError'));};
|
||||
const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},ms);
|
||||
signal.addEventListener('abort',abort,{once:true});
|
||||
});
|
||||
}
|
||||
async function watchTask() {
|
||||
stopWatcher(); const epoch=state.epoch; const controller=new AbortController(); state.controller=controller;
|
||||
const signal=controller.signal; const id=state.view.task.id; setBusy(true);
|
||||
try {
|
||||
if (value('tracking')==='stream' && state.card?.capabilities?.streaming && !isStopped(state.view.task.status?.state)) {
|
||||
try {
|
||||
log('Conectando ao fluxo da tarefa');
|
||||
for await (const event of state.client.subscribe(id,signal)) {
|
||||
if (epoch!==state.epoch) return; updateTask(event);
|
||||
if (isStopped(state.view.task.status?.state)) break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
log('Fluxo interrompido; recuperando por consulta');
|
||||
}
|
||||
}
|
||||
// Always obtain the authoritative final snapshot; also recovers a fast Task
|
||||
// that completed before SubscribeToTask or a dropped/truncated SSE stream.
|
||||
let failures=0;
|
||||
while (true) {
|
||||
try {
|
||||
const task=await state.client.getTask(id,signal);
|
||||
if (epoch!==state.epoch) return;
|
||||
updateTask({type:'task',value:task}); failures=0;
|
||||
if (isStopped(task.status?.state)) break;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
if (++failures>=3 || error.code) throw error;
|
||||
log('Conexão indisponível; tentando consultar novamente');
|
||||
}
|
||||
await sleep(failures ? failures*1000 : 900,signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted && epoch===state.epoch) {
|
||||
notice(`Acompanhamento pausado: ${error.message} Use Retomar para consultar a mesma tarefa. Nenhuma mensagem foi reenviada.`);
|
||||
setStatus('Acompanhamento pausado','error'); log('Acompanhamento pausado');
|
||||
}
|
||||
} finally {
|
||||
if (epoch===state.epoch) { setBusy(false); $('message').focus(); }
|
||||
}
|
||||
}
|
||||
function authHeaders() {
|
||||
const secret=$('authValue').value;
|
||||
if (!secret) return {};
|
||||
const name=value('authHeader');
|
||||
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\r\n]/.test(secret)) throw new Error('Cabeçalho de autenticação inválido.');
|
||||
if (['content-type','accept','a2a-version','host','origin','cookie'].includes(name.toLowerCase())) throw new Error('Use Authorization ou o cabeçalho de autenticação configurado no serviço.');
|
||||
return {[name]:secret};
|
||||
}
|
||||
$('connectionForm').addEventListener('submit',async event=>{
|
||||
event.preventDefault(); notice(); setBusy(true); setStatus('Conectando…','busy');
|
||||
try {
|
||||
const candidate=new A2AClient({url:value('serviceUrl'),version:value('version')==='0.3'?'0.3':'1.0',headers:authHeaders()});
|
||||
const card=await candidate.discover(AbortSignal.timeout(12000)); candidate.version=selectVersion(card,value('version')); candidate.headers['A2A-Version']=candidate.version;
|
||||
const scope=JSON.stringify([candidate.urls.base,candidate.version,value('target'),value('tenant'),value('skill'),value('agentOverride')]);
|
||||
if (state.scope && scope!==state.scope) newConversation();
|
||||
state.scope=scope; state.client=candidate; state.card=card; state.connected=true;
|
||||
const previous=value('skill'); $('skill').replaceChildren(new Option('Seleção automática',''));
|
||||
for (const skill of card.skills || []) $('skill').append(new Option(skill.name || skill.id,skill.id));
|
||||
$('skill').value=[...$('skill').options].some(o=>o.value===previous)?previous:'';
|
||||
$('serviceName').textContent=card.name || 'Serviço A2A'; $('connectionDetail').textContent=`${(card.skills || []).length === 1 ? '1 opção disponível' : `${(card.skills || []).length} opções disponíveis`} · ${card.capabilities?.streaming ? 'Atualizações em tempo real' : 'Consulta de tarefas'}`;
|
||||
$('connectionDot').className='dot'; $('versionBadge').textContent=`A2A ${candidate.version}`;
|
||||
setStatus(state.view.task.id?'Conectado · tarefa disponível para consulta':'Pronto para conversar');
|
||||
log('Conectado ao serviço'); ids(); saveSession();
|
||||
} catch(error) {
|
||||
state.connected=false; $('connectionDot').className='dot error'; $('connectionDetail').textContent='Não foi possível conectar.';
|
||||
setStatus('Falha na conexão','error'); notice(`${error.message} Se o navegador bloquear a conexão, confira CORS_ORIGINS no serviço e use HTTP/HTTPS para servir esta página.`);
|
||||
} finally { setBusy(false); }
|
||||
});
|
||||
$('messageForm').addEventListener('submit',async event=>{
|
||||
event.preventDefault(); const text=value('message'); if(!text || !state.connected || $('sendButton').disabled) return;
|
||||
notice(); const previousTask=state.view.task; const resumeId=needsInput(previousTask.status?.state)?previousTask.id:undefined;
|
||||
state.turn=uid(); userMessage(text); $('message').value=''; $('message').style.height='auto'; setBusy(true); setStatus('Enviando mensagem…','busy');
|
||||
const messageId=uid();
|
||||
const business_context={customer_key:value('customerKey')||null,contract_key:value('contractKey')||null,interaction_key:messageId,session_key:state.contextId};
|
||||
const data={message_id:messageId,business_context,context:{business_context}};
|
||||
if(value('customerKey')) data.customer_key=value('customerKey');
|
||||
if(value('contractKey')) data.contract_key=value('contractKey');
|
||||
try {
|
||||
const gateway=value('target')==='gateway';
|
||||
const result=await state.client.sendMessage(text,{contextId:state.contextId,taskId:resumeId,tenantId:value('tenant')||'default',
|
||||
agentId:gateway?value('agentOverride'):value('skill'),backendId:gateway?value('skill'):undefined,data});
|
||||
if(result.type==='message') {
|
||||
state.view=new TaskView(); if(result.value.contextId) state.contextId=result.value.contextId;
|
||||
renderResponse(displayResponse(result.value)); setStatus('Resposta recebida'); ids(); saveSession(); setBusy(false);
|
||||
} else if(result.type==='task') {
|
||||
state.view=new TaskView(); state.lastState=''; updateTask(result); log(`Tarefa ${state.view.task.id.slice(0,8)} recebida`); await watchTask();
|
||||
} else throw new Error('Resposta de envio inesperada.');
|
||||
} catch(error) {
|
||||
notice(`Não foi possível confirmar o envio: ${error.message} Ele pode ter sido aceito pelo serviço; não houve reenvio automático.`);
|
||||
setStatus('Envio não confirmado','error'); setBusy(false);
|
||||
}
|
||||
});
|
||||
$('resumeButton').addEventListener('click',()=>{notice();state.turn ||= uid();watchTask();});
|
||||
$('cancelButton').addEventListener('click',async()=>{
|
||||
const id=state.view.task.id; if(!id || !state.connected) return;
|
||||
stopWatcher(); setBusy(true); setStatus('Solicitando cancelamento…','busy');
|
||||
try { updateTask({type:'task',value:await state.client.cancelTask(id)});log('Cancelamento confirmado pelo serviço');notice(); }
|
||||
catch(error) {
|
||||
try {updateTask({type:'task',value:await state.client.getTask(id)});} catch { /* Keep the last known Task. */ }
|
||||
notice(`O serviço não confirmou o cancelamento: ${error.message} Consulte a tarefa com Retomar.`);
|
||||
} finally {setBusy(false);}
|
||||
});
|
||||
$('newConversation').addEventListener('click',newConversation);
|
||||
$('message').addEventListener('keydown',event=>{if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();$('messageForm').requestSubmit();}});
|
||||
$('message').addEventListener('input',()=>{ $('message').style.height='auto';$('message').style.height=`${Math.min($('message').scrollHeight,150)}px`; });
|
||||
for(const button of document.querySelectorAll('[data-prompt]')) button.addEventListener('click',()=>{if(!$('message').disabled){$('message').value=button.dataset.prompt;$('message').focus();}else notice('Conecte ao serviço para iniciar a conversa.');});
|
||||
$('target').addEventListener('change',()=>{ $('skillLabel').firstChild.textContent=value('target')==='gateway'?'Backend no Gateway':'Agente'; });
|
||||
for(const id of ['serviceUrl','version','target','tenant','skill','agentOverride','authHeader','authValue']) $(id).addEventListener('change',()=>{
|
||||
if(state.connected){state.connected=false;$('connectionDot').className='dot muted';setStatus('Configuração alterada · conecte novamente');controls();}
|
||||
});
|
||||
try {
|
||||
const saved=JSON.parse(sessionStorage.getItem(savedKey)||'null');
|
||||
if(saved?.serviceUrl){
|
||||
$('serviceUrl').value=saved.serviceUrl;$('version').value=saved.version||'auto';$('target').value=saved.target||'backend';$('tenant').value=saved.tenant||'default';$('agentOverride').value=saved.agentOverride||'';
|
||||
if(saved.skill)$('skill').append(new Option(saved.skill,saved.skill));$('skill').value=saved.skill||'';
|
||||
state.contextId=saved.contextId||uid();state.view=new TaskView(saved.taskId?{id:saved.taskId,contextId:state.contextId,status:{state:saved.taskState}}:{});
|
||||
state.scope=JSON.stringify([saved.serviceUrl,saved.version,saved.target||'backend',saved.tenant||'default',saved.skill||'',saved.agentOverride||'']);
|
||||
if(saved.taskId)notice('Uma tarefa desta aba está disponível. Conecte ao mesmo serviço e clique em Retomar para consultar o resultado.');
|
||||
}
|
||||
} catch { /* An unavailable or invalid saved session starts a fresh conversation. */ }
|
||||
const requestedService=new URLSearchParams(location.search).get('service');
|
||||
if(requestedService)$('serviceUrl').value=requestedService;
|
||||
else if(!state.scope && location.port==='8000')$('serviceUrl').value=location.origin;
|
||||
$('target').dispatchEvent(new Event('change'));ids();controls();
|
||||
52
apps/agent_frontend_a2a/index.html
Normal file
52
apps/agent_frontend_a2a/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>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>
|
||||
1
apps/agent_frontend_a2a/package.json
Normal file
1
apps/agent_frontend_a2a/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"agent-framework-a2a-frontend","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
|
||||
1
apps/agent_frontend_a2a/styles.css
Normal file
1
apps/agent_frontend_a2a/styles.css
Normal file
File diff suppressed because one or more lines are too long
86
apps/agent_frontend_a2a/tests/client.test.js
Normal file
86
apps/agent_frontend_a2a/tests/client.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { A2AClient, TaskView, displayResponse, parseSSE, serviceURLs, selectVersion, normalizeResult, isStopped, needsInput, RpcError } from '../a2a-client.js';
|
||||
|
||||
function bodyFor(text, size = 1) {
|
||||
const bytes=new TextEncoder().encode(text);
|
||||
return new ReadableStream({start(controller){for(let i=0;i<bytes.length;i+=size)controller.enqueue(bytes.slice(i,i+size));controller.close();}});
|
||||
}
|
||||
|
||||
test('SSE handles fragmented UTF-8, CRLF, comments and multi-line data',async()=>{
|
||||
const text=': ping\r\nid: 1\r\nevent: message\r\ndata: {"text":\r\ndata: "Olá 👋"}\r\n\r\ndata: second\n\n';
|
||||
const frames=[];for await(const frame of parseSSE(bodyFor(text)))frames.push(frame);
|
||||
assert.deepEqual(frames,['{"text":\n"Olá 👋"}','second']);
|
||||
});
|
||||
test('SSE discards an unfinished frame for recovery by GetTask',async()=>{
|
||||
const frames=[];for await(const frame of parseSSE(bodyFor('data: complete\n\ndata: truncated')))frames.push(frame);
|
||||
assert.deepEqual(frames,['complete']);
|
||||
});
|
||||
test('service URLs accept a base or /a2a and reject embedded credentials',()=>{
|
||||
assert.equal(serviceURLs('http://localhost:8000/a2a/').rpc,'http://localhost:8000/a2a');
|
||||
assert.equal(serviceURLs('https://host/prefix').card,'https://host/prefix/.well-known/agent-card.json');
|
||||
for(const url of ['javascript:alert(1)','https://user:secret@host','https://host?token=secret'])assert.throws(()=>serviceURLs(url));
|
||||
});
|
||||
test('version selection uses JSONRPC interfaces and allows explicit legacy compatibility',()=>{
|
||||
assert.equal(selectVersion({supportedInterfaces:[{protocolBinding:'JSONRPC',protocolVersion:'1.0'}]}),'1.0');
|
||||
assert.equal(selectVersion({protocolVersion:'0.3.0'}),'0.3');
|
||||
assert.equal(selectVersion({},'0.3'),'0.3');
|
||||
assert.throws(()=>selectVersion({supportedInterfaces:[{protocolBinding:'GRPC',protocolVersion:'1.0'}]}));
|
||||
});
|
||||
test('both versions normalize task, message and streaming events',()=>{
|
||||
assert.equal(normalizeResult({statusUpdate:{taskId:'t'}}).type,'status');
|
||||
assert.equal(normalizeResult({kind:'artifact-update',taskId:'t'}).type,'artifact');
|
||||
assert.equal(normalizeResult({message:{messageId:'m'}}).type,'message');
|
||||
assert.throws(()=>normalizeResult({unexpected:true}));
|
||||
});
|
||||
test('TaskView updates status, replaces snapshots and appends artifact chunks',()=>{
|
||||
const view=new TaskView({id:'t',artifacts:[]});
|
||||
view.apply({type:'artifact',value:{taskId:'t',artifact:{artifactId:'a',parts:[{text:'Hel'}]}}});
|
||||
view.apply({type:'artifact',value:{taskId:'t',append:true,artifact:{artifactId:'a',parts:[{text:'lo'}]}}});
|
||||
assert.equal(displayResponse(view.task).text,'Hello');
|
||||
view.apply({type:'status',value:{taskId:'t',contextId:'c',status:{state:'TASK_STATE_COMPLETED'}}});
|
||||
assert.ok(isStopped(view.task.status.state));assert.ok(needsInput('input-required'));
|
||||
assert.throws(()=>view.apply({type:'status',value:{taskId:'other',status:{state:'working'}}}));
|
||||
});
|
||||
test('framework text/data duplicates render once and native agent metadata survives',()=>{
|
||||
const native={text:'answer',metadata:{agent_responses:[{agent:'billing',text:'answer'}]}};
|
||||
const task={artifacts:[{parts:[{text:'answer'},{data:native}]}],status:{message:{parts:[{text:'answer'}]}}};
|
||||
assert.deepEqual(displayResponse(task),native);
|
||||
const wrapped={parts:[{data:{text:'answer',metadata:{a2a_result:{task}}}}]};
|
||||
assert.deepEqual(displayResponse(wrapped),native);
|
||||
});
|
||||
for(const legacy of [false,true]){
|
||||
test(`send/get/cancel use correct envelopes and credentials (${legacy?'0.3':'1.0'})`,async()=>{
|
||||
const requests=[];
|
||||
const fetchImpl=async function(url,options){
|
||||
assert.equal(this,globalThis);
|
||||
const body=JSON.parse(options.body);requests.push({url,options,body});
|
||||
return Response.json({jsonrpc:'2.0',id:body.id,result:body.method.includes('send')||body.method==='SendMessage' ? (legacy?{kind:'task',id:'t',status:{state:'working'}}:{task:{id:'t',status:{state:'TASK_STATE_WORKING'}}}):{id:'t'}});
|
||||
};
|
||||
const client=new A2AClient({url:'https://service',version:legacy?'0.3':'1.0',headers:{Authorization:'Bearer test'},fetchImpl});
|
||||
await client.sendMessage('hello',{contextId:'c',agentId:'a',backendId:'billing',tenantId:'tenant',data:{business_context:{customer_key:'customer'}}});
|
||||
await client.getTask('t');await client.cancelTask('t');
|
||||
assert.deepEqual(requests.map(r=>r.body.method),legacy?['message/send','tasks/get','tasks/cancel']:['SendMessage','GetTask','CancelTask']);
|
||||
const params=requests[0].body.params;
|
||||
assert.equal(params.message.role,legacy?'user':'ROLE_USER');assert.equal(params.message.metadata.backend_id,'billing');
|
||||
assert.equal(params.message.metadata.agent_id,'a');assert.equal(params.message.contextId,'c');
|
||||
assert.equal(legacy?params.configuration.blocking:params.configuration.returnImmediately,!legacy);
|
||||
assert.equal(requests[0].options.headers.Authorization,'Bearer test');
|
||||
assert.equal(requests[0].options.credentials,'omit');
|
||||
});
|
||||
}
|
||||
test('JSON-RPC errors and mismatched response IDs never become successful responses',async()=>{
|
||||
const bad=new A2AClient({url:'https://service',fetchImpl:async()=>Response.json({jsonrpc:'2.0',id:'wrong',result:{}})});
|
||||
await assert.rejects(bad.getTask('t'),/JSON-RPC/);
|
||||
const failure=new A2AClient({url:'https://service',fetchImpl:async(url,o)=>Response.json({jsonrpc:'2.0',id:JSON.parse(o.body).id,error:{code:-32001,message:'Task missing'}})});
|
||||
await assert.rejects(failure.getTask('t'),RpcError);
|
||||
});
|
||||
test('POST SSE supports auth headers, validates envelopes and streams canonical events',async()=>{
|
||||
const client=new A2AClient({url:'https://service',headers:{'x-api-key':'test'},fetchImpl:async(url,o)=>{
|
||||
assert.equal(o.method,'POST');assert.equal(o.headers['x-api-key'],'test');
|
||||
const body=JSON.parse(o.body);assert.equal(body.method,'SubscribeToTask');
|
||||
return new Response(bodyFor(`data: ${JSON.stringify({jsonrpc:'2.0',id:body.id,result:{statusUpdate:{taskId:'t',status:{state:'TASK_STATE_COMPLETED'}}}})}\n\n`),{headers:{'content-type':'text/event-stream'}});
|
||||
}});
|
||||
const events=[];for await(const event of client.subscribe('t'))events.push(event);
|
||||
assert.equal(events[0].type,'status');assert.equal(events[0].value.taskId,'t');
|
||||
});
|
||||
32
apps/agent_frontend_a2a/tests/integration.test.js
Normal file
32
apps/agent_frontend_a2a/tests/integration.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Run against examples/a2a_frontend_demo.py with A2A_DEMO_URL=http://127.0.0.1:8765.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { A2AClient, TaskView, displayResponse, isStopped, stateName } from '../a2a-client.js';
|
||||
const url=process.env.A2A_DEMO_URL;
|
||||
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function complete(client,task){
|
||||
const deadline=Date.now()+6000;
|
||||
while(!isStopped(task.status?.state)&&Date.now()<deadline){await delay(50);task=await client.getTask(task.id);}
|
||||
assert.ok(isStopped(task.status?.state));return task;
|
||||
}
|
||||
for(const version of ['1.0','0.3']){
|
||||
test(`SDK integration: submit, subscribe, poll and resume (${version})`,{skip:!url},async()=>{
|
||||
const client=new A2AClient({url,version});const card=await client.discover();assert.ok(card.capabilities.streaming);
|
||||
const first=await client.sendMessage('hello',{contextId:crypto.randomUUID()});assert.equal(first.type,'task');assert.ok(!isStopped(first.value.status.state));
|
||||
const view=new TaskView(first.value);let count=0;
|
||||
for await(const event of client.subscribe(first.value.id)){view.apply(event);count++;}
|
||||
assert.ok(count>0);const final=await client.getTask(first.value.id);
|
||||
assert.equal(stateName(final.status.state),'completed');assert.match(displayResponse(final).text,/hello/);
|
||||
const prompt=await client.sendMessage('/confirmar',{contextId:crypto.randomUUID()});
|
||||
const waiting=await complete(client,prompt.value);assert.equal(stateName(waiting.status.state),'input-required');
|
||||
const resumed=await client.sendMessage('sim',{contextId:waiting.contextId,taskId:waiting.id});
|
||||
const finished=await complete(client,resumed.value);assert.equal(finished.id,waiting.id);assert.equal(stateName(finished.status.state),'completed');
|
||||
});
|
||||
}
|
||||
test('SDK integration: cancellation and failed tasks',{skip:!url},async()=>{
|
||||
const client=new A2AClient({url});
|
||||
const slow=await client.sendMessage('/lento',{contextId:crypto.randomUUID()});
|
||||
await delay(50);const canceled=await client.cancelTask(slow.value.id);assert.equal(stateName(canceled.status.state),'canceled');
|
||||
const failed=await client.sendMessage('/erro',{contextId:crypto.randomUUID()});
|
||||
assert.equal(stateName((await complete(client,failed.value)).status.state),'failed');
|
||||
});
|
||||
7
apps/agent_gateway/Dockerfile.a2a
Normal file
7
apps/agent_gateway/Dockerfile.a2a
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY libs/agent_framework /agent_framework
|
||||
COPY apps/agent_gateway /app
|
||||
RUN pip install --no-cache-dir -e '/agent_framework[a2a]' -r requirements.txt
|
||||
ENV ENABLE_A2A=true
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
27
apps/agent_gateway/config/backends.a2a.example.yaml
Normal file
27
apps/agent_gateway/config/backends.a2a.example.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
#default_backend: native
|
||||
#backends:
|
||||
# native:
|
||||
# url: http://localhost:8000
|
||||
# description: Agente nativo do framework
|
||||
# keywords: [conta, fatura]
|
||||
# # protocol omitido equivale a native
|
||||
# external:
|
||||
# protocol: a2a
|
||||
# a2a_version: '1.0' # ou '0.3'
|
||||
# url: https://external-agent.example
|
||||
# a2a_rpc_path: /a2a # endpoint definido pelo operador a partir do Agent Card
|
||||
# a2a_agent_card_path: /.well-known/agent-card.json
|
||||
# description: Agente externo A2A
|
||||
# keywords: [suporte]
|
||||
# a2a_headers_env:
|
||||
# Authorization: EXTERNAL_AGENT_AUTHORIZATION # valor completo: Bearer <token>
|
||||
|
||||
default_backend: agent_template
|
||||
|
||||
backends:
|
||||
agent_template:
|
||||
protocol: a2a
|
||||
a2a_version: "1.0"
|
||||
url: http://localhost:8000
|
||||
a2a_rpc_path: /a2a
|
||||
a2a_agent_card_path: /.well-known/agent-card.json
|
||||
248
docs/A2A_ASSINCRONO.md
Normal file
248
docs/A2A_ASSINCRONO.md
Normal 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
145
docs/A2A_IMPLEMENTACAO.md
Normal 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
|
||||
43
examples/a2a_frontend_demo.py
Normal file
43
examples/a2a_frontend_demo.py
Normal 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')
|
||||
@@ -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"]
|
||||
|
||||
|
||||
1
libs/agent_framework/src/agent_framework/a2a/__init__.py
Normal file
1
libs/agent_framework/src/agent_framework/a2a/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Optional A2A interoperability; imported only when enabled."""
|
||||
108
libs/agent_framework/src/agent_framework/a2a/client.py
Normal file
108
libs/agent_framework/src/agent_framework/a2a/client.py
Normal 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},
|
||||
}
|
||||
10
libs/agent_framework/src/agent_framework/a2a/progress.py
Normal file
10
libs/agent_framework/src/agent_framework/a2a/progress.py
Normal 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 {})
|
||||
181
libs/agent_framework/src/agent_framework/a2a/server.py
Normal file
181
libs/agent_framework/src/agent_framework/a2a/server.py
Normal 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()],
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
9
templates/agent_template_backend/.env.a2a.example
Normal file
9
templates/agent_template_backend/.env.a2a.example
Normal 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":[]}]
|
||||
8
templates/agent_template_backend/Dockerfile.a2a
Normal file
8
templates/agent_template_backend/Dockerfile.a2a
Normal 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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
258
tests/test_a2a_async_tasks.py
Normal file
258
tests/test_a2a_async_tasks.py
Normal 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})]
|
||||
81
tests/test_a2a_http_streaming.py
Normal file
81
tests/test_a2a_http_streaming.py
Normal 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()
|
||||
188
tests/test_a2a_interoperability.py
Normal file
188
tests/test_a2a_interoperability.py
Normal 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'
|
||||
Reference in New Issue
Block a user