UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

362 lines • 16 kB
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