Files
2026-10-21 08:23:55 -03:00

168 lines
8.8 KiB
JavaScript

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