@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
362 lines • 16 kB
JavaScript
import { globalPoppyAuthenticate, globalPoppyIssuer, } from './auth.js';
import { isPoppyId, PoppyError, parseEmptyBody, parseSendBody } from './protocol.js';
/** Match the path after the endpoint: `null` → 404, a method mismatch → 405. */
function matchRoute(method, rest) {
const allow = (expected, route) => (method === expected ? route : 405);
if (rest.length === 0)
return allow('POST', { kind: 'start' });
if (rest.length !== 2)
return 404;
const [id = '', action] = rest;
switch (action) {
case 'messages':
return allow('POST', { kind: 'messages', id });
case 'events':
return allow('GET', { kind: 'events', id });
case 'handoff':
return allow('POST', { kind: 'handoff', id });
case 'close':
return allow('POST', { kind: 'close', id });
default:
return 404;
}
}
/** The scheme the request authenticated with — what a challenge of ours is written in. */
function schemeOf(exchange) {
const header = exchange.headers.authorization ?? '';
return /^bearer\s/i.test(header) ? 'Bearer' : 'DPoP';
}
/** `wait` in ms, capped; absent → 0. */
function waitMs(exchange, maxSeconds) {
const raw = exchange.query.wait;
if (raw === undefined || raw === '')
return 0;
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds < 0) {
throw new PoppyError('invalid_request', 'wait must be a non-negative number of seconds');
}
return Math.min(seconds, maxSeconds) * 1000;
}
function cursorOf(exchange, header = false) {
const raw = (header ? exchange.headers['last-event-id'] : undefined) ?? exchange.query.cursor;
return raw === undefined || raw === '' ? undefined : raw.trim();
}
/**
* The Poppy conversation endpoint (§7) — `POST {endpoint}`, `POST {endpoint}/{id}/messages`,
* `GET {endpoint}/{id}/events` (JSON, long-poll with `wait`, or SSE), `POST …/handoff` and
* `POST …/close`. Returns a handler that answers everything under `/{path}` and reports `false`
* for anything else, so it can sit in front of the router.
*
* Every request is authenticated BEFORE its body is read, by the resolver of
* `Symbol.for('@adonis-agora/poppy:authenticate')` (`@adonis-agora/authkit-server`) or the one
* given. Without either the endpoint answers `501`.
*
* @experimental Tracks Personal Agent Protocol Draft 0.1 (https://personalagentprotocol.org/docs/spec),
* a spec still in development: this API WILL change as the spec evolves — possibly in breaking
* ways, outside semver majors while it is a draft.
*/
export function createPoppyHandler(options) {
const base = `/${options.path}`;
const maxBody = options.maxBodyBytes ?? 1024 * 1024;
const maxWait = options.maxWaitSeconds ?? 30;
const conversations = options.conversations;
async function authenticate(exchange) {
const resolver = options.authenticate ?? globalPoppyAuthenticate();
if (!resolver) {
throw new PoppyError('not_implemented', 'Poppy conversations are not enabled: no Session Token resolver is installed');
}
// No options: a turn's scopes are decided per tool, and the `resource` a token must carry is
// the resolver's to derive from the URL (the `resource` of the `poppy` protocol entry, if any).
const result = await resolver({
method: exchange.method,
url: exchange.url,
headers: exchange.rawHeaders,
});
if (result.ok)
return result.principal;
exchange.json(result.status, {
error: result.error,
...(result.description !== undefined ? { error_description: result.description } : {}),
...(result.scope !== undefined ? { scope: result.scope } : {}),
}, {
...(result.headers ?? {}),
'WWW-Authenticate': result.wwwAuthenticate,
'Cache-Control': 'no-store',
});
return null;
}
async function stream(exchange, principal, id) {
const conversation = await conversations.owned(principal, id);
const controller = new AbortController();
const stop = () => controller.abort();
const iterator = conversations.stream(conversation, cursorOf(exchange, true), controller.signal);
let first;
try {
// The cursor is checked before the stream opens, so a bad one is still a JSON error.
first = await iterator.next();
}
catch (error) {
await iterator.return(undefined);
throw error;
}
const writer = exchange.openStream({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
writer.signal.addEventListener('abort', stop, { once: true });
if (writer.signal.aborted)
stop();
const maxTimer = setTimeout(stop, options.streamMaxMs ?? 300_000);
const keepAlive = setInterval(() => writer.write(': keep-alive\n\n'), options.keepAliveMs ?? 15_000);
maxTimer.unref?.();
keepAlive.unref?.();
try {
let next = first;
while (!next.done) {
const item = next.value;
if (item.kind === 'event') {
writer.write(`id: ${item.event.id}\ndata: ${JSON.stringify(item.event)}\n\n`);
}
else if (item.kind === 'delta') {
writer.write(`event: text-delta\ndata: ${JSON.stringify({ message_id: item.messageId, text: item.text })}\n\n`);
}
next = await iterator.next();
}
}
finally {
clearTimeout(maxTimer);
clearInterval(keepAlive);
await iterator.return(undefined);
writer.end();
}
}
async function route(exchange, route) {
const principal = await authenticate(exchange);
if (principal === null)
return;
switch (route.kind) {
case 'start': {
const body = parseSendBody(await exchange.readBody(maxBody), { allowParent: true });
const started = await conversations.start(principal, body);
return answerPost(exchange, principal, started.conversationId, started);
}
case 'messages': {
if (!isPoppyId(route.id))
throw new PoppyError('conversation_not_found');
// The conversation is checked before the body is parsed: an unknown one is a 404.
await conversations.owned(principal, route.id);
const body = parseSendBody(await exchange.readBody(maxBody), { allowParent: false });
const sent = await conversations.send(principal, route.id, body);
return answerPost(exchange, principal, route.id, sent);
}
case 'events': {
if (!isPoppyId(route.id))
throw new PoppyError('conversation_not_found');
if ((exchange.headers.accept ?? '').includes('text/event-stream')) {
return stream(exchange, principal, route.id);
}
const wait = waitMs(exchange, maxWait);
const cursor = cursorOf(exchange);
const result = await conversations.read(principal, route.id, {
...(cursor !== undefined ? { cursor } : {}),
waitMs: wait,
});
return exchange.json(200, result);
}
case 'handoff': {
if (!isPoppyId(route.id))
throw new PoppyError('conversation_not_found');
await conversations.owned(principal, route.id);
parseEmptyBody(await exchange.readBody(maxBody));
const state = await conversations.requestHandoff(principal, route.id);
return exchange.json(202, { conversation_id: route.id, ...state });
}
case 'close': {
if (!isPoppyId(route.id))
throw new PoppyError('conversation_not_found');
await conversations.owned(principal, route.id);
parseEmptyBody(await exchange.readBody(maxBody));
const state = await conversations.closeByAgent(principal, route.id);
return exchange.json(200, { conversation_id: route.id, ...state });
}
}
}
/**
* A message POST's answer: its status and body — or, with `wait`, a read of the events from
* `cursor` (the start without one), still under the POST's status (§7.3).
*/
async function answerPost(exchange, principal, conversationId, answer) {
if (exchange.query.wait === undefined)
return exchange.json(answer.status, answer.body);
const wait = waitMs(exchange, maxWait);
const conversation = await conversations.owned(principal, conversationId);
const cursor = cursorOf(exchange);
const read = await conversations.readOwned(conversation, {
...(cursor !== undefined ? { cursor } : {}),
waitMs: wait,
});
return exchange.json(answer.status, read);
}
return async function handle(exchange) {
const path = exchange.path.replace(/\/+$/, '');
if (path !== base && !path.startsWith(`${base}/`))
return false;
const rest = path
.slice(base.length)
.split('/')
.filter((segment) => segment !== '');
let decoded;
try {
decoded = rest.map((segment) => decodeURIComponent(segment));
}
catch {
decoded = ['', ''];
}
const matched = matchRoute(exchange.method.toUpperCase(), decoded);
try {
if (matched === 404)
throw new PoppyError('not_found');
if (matched === 405)
throw new PoppyError('method_not_allowed');
await route(exchange, matched);
}
catch (error) {
const poppy = error instanceof PoppyError
? error
: error?.name === 'PoppyError'
? error
: null;
if (poppy === null)
options.onError?.(error);
const answer = poppy ?? new PoppyError('server_error', 'Internal error');
exchange.json(answer.status, answer.toJSON(), answer.code === 'sign_in_required'
? { 'WWW-Authenticate': `${schemeOf(exchange)} error="sign_in_required"` }
: answer.code === 'method_not_allowed'
? { Allow: matched === 405 ? allowFor(decoded) : '' }
: {});
}
return true;
};
}
function allowFor(rest) {
return rest.length === 2 && rest[1] === 'events' ? 'GET' : 'POST';
}
// ── exchanges ─────────────────────────────────────────────────────────────────
function rawLowerHeaders(headers) {
const out = {};
for (const [name, value] of Object.entries(headers))
out[name.toLowerCase()] = value;
return out;
}
function lowerHeaders(headers) {
const out = {};
for (const [name, value] of Object.entries(headers)) {
out[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
}
return out;
}
async function readStream(source, limit) {
const chunks = [];
let size = 0;
for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > limit)
throw new PoppyError('request_too_large', 'Request body is too large');
chunks.push(buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
function writer(res, req) {
const controller = new AbortController();
const abort = () => controller.abort();
req.on('close', abort);
res.on('close', abort);
return {
write: (chunk) => {
if (!res.writableEnded)
res.write(chunk);
},
end: () => {
if (!res.writableEnded)
res.end();
},
signal: controller.signal,
};
}
/** A {@link PoppyExchange} over a plain Node request — any framework, or a bare `http` server. */
export function nodeExchange(req, res, options) {
const target = new URL(req.url ?? '/', options.origin);
return {
method: req.method ?? 'GET',
path: target.pathname,
query: Object.fromEntries(target.searchParams),
url: target.toString(),
headers: lowerHeaders(req.headers),
rawHeaders: rawLowerHeaders(req.headers),
readBody: (limit) => readStream(req, limit),
json(status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
res.end(JSON.stringify(body));
},
openStream(headers) {
res.writeHead(200, headers);
res.flushHeaders?.();
return writer(res, req);
},
};
}
/**
* The public origin of a request: `baseUrl` when configured; else the request's protocol and host
* (Adonis honours `X-Forwarded-*` from trusted proxies) — upgraded to `https` when the Poppy issuer
* is, as a TLS-terminating proxy would otherwise make every DPoP `htu` mismatch.
*/
export function publicOrigin(request, baseUrl, issuer = globalPoppyIssuer()) {
if (baseUrl !== undefined)
return baseUrl.replace(/\/+$/, '');
const protocol = request.protocol === 'http' && issuer?.startsWith('https:') ? 'https' : request.protocol;
return `${protocol}://${request.host}`;
}
/** A {@link PoppyExchange} over an Adonis request — what the Poppy server middleware builds. */
export function adonisExchange(ctx, options) {
const origin = publicOrigin({ protocol: ctx.request.protocol(), host: ctx.request.host() ?? 'localhost' }, options.baseUrl);
const original = ctx.request.url(true) ?? ctx.request.url() ?? '/';
const target = new URL(original, `${origin}/`);
return {
method: ctx.request.method(),
path: target.pathname,
query: Object.fromEntries(target.searchParams),
url: `${origin}${target.pathname}${target.search}`,
headers: lowerHeaders(ctx.request.headers()),
rawHeaders: rawLowerHeaders(ctx.request.headers()),
async readBody(limit) {
// Mounted ahead of the bodyparser: the body is still on the socket.
const parsed = ctx.request.raw?.();
if (typeof parsed === 'string')
return parsed;
return readStream(ctx.request.request, limit);
},
json(status, body, headers = {}) {
ctx.response.status(status);
for (const [name, value] of Object.entries(headers))
ctx.response.header(name, value);
ctx.response.header('Content-Type', 'application/json');
ctx.response.send(JSON.stringify(body));
},
openStream(headers) {
const raw = ctx.response.response;
// Headers the request already set (a cookie) — `writeHead` on the raw response skips them.
for (const [name, value] of Object.entries(ctx.response.getHeaders())) {
if (value !== undefined && !(name in headers)) {
raw.setHeader(name, value);
}
}
raw.writeHead(200, headers);
raw.flushHeaders?.();
return writer(raw, ctx.request.request);
},
};
}
//# sourceMappingURL=handler.js.map