@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
324 lines • 15 kB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
import { scopeRole } from './gate.js';
import { PERSONAL_AGENT_ROLE } from './permission-tool.js';
import { A2A_CONTENT_TYPE, A2aError, parseSendMessage, } from './protocol.js';
import { runA2aTurn } from './turn.js';
const DEFAULT_PAGE_SIZE = 50;
/**
* The actor of an identity-only turn: stable per (personal agent, its user), never an account id —
* the `pa:` prefix keeps it from ever colliding with one.
*/
export function personalAgentActorId(issuer, sub) {
return `pa:${createHash('sha256').update(`${issuer}\n${sub}`).digest('hex').slice(0, 40)}`;
}
async function actorFor(caller, options) {
const extra = options.roles?.(caller) ?? (caller.delegation?.scopes ?? []).map(scopeRole);
const defaults = {
id: caller.delegation?.accountId ?? personalAgentActorId(caller.issuer, caller.sub),
roles: [...new Set([PERSONAL_AGENT_ROLE, ...extra])],
};
const actor = options.actor ? await options.actor(caller, defaults) : defaults;
// `personal_agent` is ALWAYS there, whatever the hook returned: it is what puts the actor behind
// `personalAgentGate`.
return { ...actor, roles: [...new Set([PERSONAL_AGENT_ROLE, ...(actor.roles ?? [])])] };
}
/**
* Where the FIRST message of a conversation is claimed: it has no `contextId` yet, so a retry of it
* could only be told apart from a new conversation by this per-(brand, personal-agent user) slot.
*/
function newConversationKey(brand, caller) {
return `new:${brand}:${personalAgentActorId(caller.issuer, caller.sub)}`;
}
function sendA2a(ctx, status, body) {
ctx.response.status(status);
ctx.response.header('Content-Type', A2A_CONTENT_TYPE);
ctx.response.send(JSON.stringify(body));
}
/** A bare status, no A2A body — unmatched routes and unknown brands (PACT §2.2). */
function sendBare(ctx, status) {
ctx.response.status(status);
ctx.response.header('Content-Type', 'text/plain; charset=utf-8');
ctx.response.send(status === 404 ? 'Not Found' : 'Method Not Allowed');
}
/** The request body, read straight off the socket: no bodyparser runs ahead of this surface. */
async function readBody(ctx, limit) {
const parsed = ctx.request.raw?.();
if (typeof parsed === 'string')
return parsed;
const chunks = [];
let size = 0;
for await (const chunk of ctx.request.request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > limit)
throw new A2aError('INVALID_PARAMS', 'Request body is too large');
chunks.push(buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
/** The request path without the query string. */
function pathOf(ctx) {
return (ctx.request.url() ?? '').split('?')[0] ?? '';
}
/**
* Match `rest` (the path after `/{path}/{brand}/`) against the A2A operations. `null` → 404, a
* method mismatch → 405 — both decided before any authentication (PACT §2.2).
*/
function matchRoute(method, rest) {
const allow = (methods, route) => (methods.includes(method) ? route : 405);
if (rest === '.well-known/agent-card.json')
return allow(['GET'], { kind: 'card' });
if (rest === 'message:send')
return allow(['POST'], { kind: 'send' });
if (rest === 'message:stream')
return allow(['POST'], { kind: 'unsupported' });
if (rest === 'extendedAgentCard')
return allow(['GET'], { kind: 'unsupported' });
if (rest === 'tasks')
return allow(['GET'], { kind: 'listTasks' });
const segments = rest.split('/');
if (segments[0] !== 'tasks' || !segments[1])
return 404;
const [, taskSegment, sub, configId, ...extra] = segments;
if (extra.length > 0)
return 404;
if (sub === undefined) {
const action = /^(.+):(cancel|subscribe)$/.exec(taskSegment);
if (action?.[2] === 'cancel') {
return allow(['POST'], { kind: 'task', taskId: action[1] ?? taskSegment });
}
if (action?.[2] === 'subscribe')
return allow(['POST', 'GET'], { kind: 'unsupported' });
return allow(['GET'], { kind: 'task', taskId: taskSegment });
}
if (sub !== 'pushNotificationConfigs')
return 404;
return configId === undefined
? allow(['GET', 'POST'], { kind: 'push' })
: allow(['GET', 'DELETE'], { kind: 'push' });
}
/**
* The A2A 1.0 HTTP+JSON surface (PACT §2, §4, §5.5) over the agent runtime. Returns a request
* handler that answers everything under `/{path}/` and reports `false` for anything else, so it can
* sit in front of the router.
*/
export function createA2aHandler(options) {
const prefix = `/${options.path}/`;
const toolRoles = (name) => options.service.toolRoles?.(name) ?? options.registry.spec(name)?.roles;
const interfaceUrl = (ctx, brand) => {
const origin = options.baseUrl?.replace(/\/+$/, '') ??
`${ctx.request.protocol()}://${ctx.request.host() ?? 'localhost'}`;
return `${origin}/${options.path}/${encodeURIComponent(brand.id)}`;
};
async function card(ctx, brand) {
const url = interfaceUrl(ctx, brand);
ctx.response.header('Content-Type', 'application/json');
ctx.response.header('Access-Control-Allow-Origin', '*');
ctx.response.send(JSON.stringify({
name: brand.card.name,
description: brand.card.description,
supportedInterfaces: [{ url, protocolBinding: 'HTTP+JSON', protocolVersion: '1.0' }],
...(brand.card.provider ? { provider: brand.card.provider } : {}),
version: brand.card.version ?? '1.0.0',
...(brand.card.documentationUrl ? { documentationUrl: brand.card.documentationUrl } : {}),
...(brand.card.iconUrl ? { iconUrl: brand.card.iconUrl } : {}),
capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
...(await options.auth.cardSecurity(ctx, url)),
defaultInputModes: ['text/plain'],
defaultOutputModes: ['text/plain'],
skills: brand.card.skills ?? [],
}));
}
async function send(ctx, brand, caller) {
const input = parseSendMessage(await readBody(ctx, options.maxBodyBytes));
const actor = await actorFor(caller, options);
const accountId = caller.delegation?.accountId ?? null;
if (input.contextId !== undefined) {
const context = await options.store.getContext(input.contextId);
// Another user's or another brand's context is indistinguishable from none (PACT §4.2).
if (!context ||
context.brand !== brand.id ||
context.agentIssuer !== caller.issuer ||
context.agentSub !== caller.sub) {
throw new A2aError('INVALID_PARAMS', 'Unknown contextId');
}
// A conversation that ran as an account holds that account's data: it continues only under
// a delegation for it — not identity-only (a revoked grant), not another account (§5.5).
if (context.accountRef !== null && accountId === null) {
throw new A2aError('INVALID_PARAMS', 'contextId needs the delegation it ran under');
}
if (accountId !== null && !(await options.store.bindAccount(input.contextId, accountId))) {
throw new A2aError('INVALID_PARAMS', 'contextId belongs to another account');
}
}
const claimKey = input.contextId ?? newConversationKey(brand.id, caller);
const claim = await options.store.claimMessage(claimKey, input.messageId);
if (claim.status === 'done') {
const stored = claim.reply;
return sendA2a(ctx, stored.status, stored.body);
}
if (claim.status === 'pending') {
throw new A2aError('INVALID_PARAMS', 'messageId is already in use in this context');
}
let threadId = input.contextId;
const finish = async (reply) => {
await options.store.completeMessage(claimKey, input.messageId, reply);
// A first message is also answerable from its new context — a retry may carry it by now.
if (threadId !== undefined && threadId !== claimKey) {
await options.store.claimMessage(threadId, input.messageId);
await options.store.completeMessage(threadId, input.messageId, reply);
}
sendA2a(ctx, reply.status, reply.body);
};
let turn;
try {
turn = await runA2aTurn(options.service, toolRoles, {
actor,
text: input.text,
agentName: brand.agentName,
...(input.contextId !== undefined ? { threadId: input.contextId } : {}),
delegatedScopes: caller.delegation?.scopes ?? null,
actions: options.actions,
timeoutMs: options.timeoutMs,
onStarted: async (id) => {
threadId = id;
if (input.contextId !== undefined)
return;
await options.store.createContext({
id,
brand: brand.id,
agentIssuer: caller.issuer,
agentSub: caller.sub,
accountRef: accountId,
});
},
});
}
catch (error) {
await options.store.releaseMessage(claimKey, input.messageId);
if (error.code === 'run_active') {
throw new A2aError('INVALID_PARAMS', 'contextId already has a message in progress');
}
throw error;
}
if (turn.error !== null) {
const failure = new A2aError('INTERNAL', 'The agent could not answer');
// Something already ran on the account: a retry must get this failure back, not run it again.
if (turn.actions.length > 0) {
return finish({ status: failure.httpStatus, body: failure.toJSON() });
}
await options.store.releaseMessage(claimKey, input.messageId);
throw failure;
}
const receipt = caller.delegation
? await options.auth.receipt(ctx, {
scopesUsed: [...new Set(turn.actions.flatMap((action) => action.scopes))],
actions: turn.actions.map(({ tool, argsHash }) => ({ tool, argsHash })),
})
: {};
// A2A reports what was DONE (the delegation's receipt); a refused or failed action is not one.
const done = turn.outcomes.filter((outcome) => outcome.status === 'succeeded');
const message = {
messageId: randomUUID(),
contextId: turn.threadId,
role: 'ROLE_AGENT',
parts: [
...(turn.text !== '' || done.length === 0 ? [{ text: turn.text }] : []),
...done.map((outcome) => ({
data: { action: outcome },
mediaType: 'application/json',
})),
],
...(Object.keys(receipt).length > 0 ? { metadata: receipt } : {}),
};
let reply = { message };
const missing = (turn.permission ?? []).filter((scope) => !(caller.delegation?.scopes ?? []).includes(scope));
if (missing.length > 0) {
const stepUp = await options.auth.stepUp(ctx, missing);
if (stepUp !== null) {
reply = {
task: {
id: `t-${randomUUID()}`,
contextId: turn.threadId,
status: { state: 'TASK_STATE_AUTH_REQUIRED', message },
metadata: { ...receipt, ...stepUp },
},
};
}
}
await finish({ status: 200, body: reply });
}
function listTasks(ctx) {
const raw = ctx.request.qs().pageSize;
const pageSize = raw === undefined ? DEFAULT_PAGE_SIZE : Number(raw);
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
throw new A2aError('INVALID_PARAMS', 'pageSize must be an integer from 1 to 100');
}
// Ordinary turns create no task (PACT §4.2) and step-up tasks are ephemeral.
sendA2a(ctx, 200, { tasks: [], nextPageToken: '', pageSize, totalSize: 0 });
}
return async function handle(ctx) {
const path = pathOf(ctx);
const method = ctx.request.method().toUpperCase();
if (path === '/.well-known/agent-card.json' && options.rootCardBrand !== undefined) {
const brand = options.brands.get(options.rootCardBrand);
if (!brand)
return false;
if (method !== 'GET') {
sendBare(ctx, 405);
return true;
}
await card(ctx, brand);
return true;
}
if (!path.startsWith(prefix))
return false;
const [brandSegment = '', ...restSegments] = path.slice(prefix.length).split('/');
let brandId;
try {
brandId = decodeURIComponent(brandSegment);
}
catch {
brandId = '';
}
const brand = options.brands.get(brandId);
const route = brand ? matchRoute(method, restSegments.join('/')) : 404;
if (!brand || route === 404 || route === 405) {
sendBare(ctx, route === 405 ? 405 : 404);
return true;
}
try {
if (route.kind === 'card') {
await card(ctx, brand);
return true;
}
// Authenticate before reading the body (PACT §3.4).
const caller = await options.auth.authenticate(ctx);
if (!caller)
return true;
switch (route.kind) {
case 'send':
await send(ctx, brand, caller);
break;
case 'listTasks':
listTasks(ctx);
break;
case 'task':
throw new A2aError('TASK_NOT_FOUND', `Task not found: ${route.taskId}`);
case 'unsupported':
throw new A2aError('UNSUPPORTED_OPERATION', 'This operation is not supported');
case 'push':
throw new A2aError('PUSH_NOTIFICATION_NOT_SUPPORTED', 'Push notifications are not supported');
}
}
catch (error) {
const a2a = error instanceof A2aError ? error : new A2aError('INTERNAL', 'Internal error');
if (!(error instanceof A2aError)) {
ctx.logger?.error({ err: error }, 'a2a: request failed');
}
sendA2a(ctx, a2a.httpStatus, a2a.toJSON());
}
return true;
};
}
//# sourceMappingURL=handler.js.map