mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 03:14:12 +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
|
||||
Reference in New Issue
Block a user