UNPKG

@adonis-agora/agent

Version:

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

178 lines • 7.53 kB
import { createHash, randomBytes } from 'node:crypto'; /** * The wire format of Personal Agent Protocol ("Poppy", Draft 0.1, §7) conversations: messages, * events, statuses and the error codes of §7.13. Hand-rolled like the A2A surface — the rules are * few and exact, and unknown fields are ignored everywhere (§3.1, §3.3). * * @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. */ /** The `protocol_version` this surface implements. */ export const POPPY_PROTOCOL_VERSION = '0.1'; /** The `type` of this surface's entry in `poppy.json` `agent.protocols` (§7.1). */ export const POPPY_PROTOCOL_TYPE = 'poppy'; /** The account scopes the protocol defines (§4.4). Companies may add their own. */ export const POPPY_READ_SCOPE = 'poppy:read'; export const POPPY_WRITE_SCOPE = 'poppy:write'; /** The conversation errors of §7.13, plus the request errors this surface answers. */ const ERRORS = { conversation_not_found: 404, conversation_closed: 409, direct_conversation_open: 409, message_id_conflict: 409, invalid_cursor: 400, cursor_expired: 410, invalid_request: 400, /** §3.3 — a request handled only with an extension the Personal Agent does not list. */ extension_required: 403, sign_in_required: 403, not_found: 404, method_not_allowed: 405, request_too_large: 413, not_implemented: 501, server_error: 500, }; /** * A Poppy error, answered as `{ "error": code, … }` like an OAuth error response (§7.13). `extra` * carries what a code adds — `conversation_id` for `direct_conversation_open`, `extension` for * `extension_required`. An app hook may throw one to refuse a request. */ export class PoppyError extends Error { code; extra; constructor(code, description, extra = {}) { super(description ?? code); this.code = code; this.extra = extra; this.name = 'PoppyError'; } get status() { return ERRORS[this.code]; } toJSON() { return { error: this.code, ...(this.message !== this.code ? { error_description: this.message } : {}), ...this.extra, }; } } /** IDs are 1–256 characters of the URL-safe base64 alphabet (§7.2). */ const ID_PATTERN = /^[A-Za-z0-9_-]{1,256}$/; export function isPoppyId(value) { return typeof value === 'string' && ID_PATTERN.test(value); } /** A new id of `kind` — `cnv_…`, `msg_…`, `evt_…`. */ export function newPoppyId(prefix) { return `${prefix}_${randomBytes(12).toString('base64url')}`; } /** A stable id derived from `seed` — what lets a re-read turn produce the same company message. */ export function derivedPoppyId(prefix, seed) { return `${prefix}_${createHash('sha256').update(seed).digest('base64url').slice(0, 22)}`; } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Parse a message request (§7.3, §7.4). Unknown fields — on the body, the message or its context — * are ignored, never refused (§3.3). Only the fields the protocol defines are kept. */ export function parseSendBody(raw, opts) { let body; try { body = raw.trim() === '' ? undefined : JSON.parse(raw); } catch { throw new PoppyError('invalid_request', 'The request body is not valid JSON'); } if (!isRecord(body) || !isRecord(body.message)) { throw new PoppyError('invalid_request', 'message is required'); } const message = body.message; if (!isPoppyId(message.id)) { throw new PoppyError('invalid_request', 'message.id must be 1-256 URL-safe base64 characters'); } if (message.sender !== 'agent' && message.sender !== 'human') { throw new PoppyError('invalid_request', 'message.sender must be "agent" or "human"'); } if (message.text !== undefined && typeof message.text !== 'string') { throw new PoppyError('invalid_request', 'message.text must be a string'); } if (message.data !== undefined && !isRecord(message.data)) { throw new PoppyError('invalid_request', 'message.data must be a JSON object'); } if (message.context !== undefined && !isRecord(message.context)) { throw new PoppyError('invalid_request', 'message.context must be a JSON object'); } if (message.text === undefined && message.data === undefined && message.context === undefined) { throw new PoppyError('invalid_request', 'A message needs text, data or context'); } const context = message.context; if (context !== undefined) { if (context.locale !== undefined && typeof context.locale !== 'string') { throw new PoppyError('invalid_request', 'context.locale must be a string'); } if (context.time_zone !== undefined && typeof context.time_zone !== 'string') { throw new PoppyError('invalid_request', 'context.time_zone must be a string'); } if (context.user_available !== undefined && typeof context.user_available !== 'boolean') { throw new PoppyError('invalid_request', 'context.user_available must be a boolean'); } } let parentConversationId; if (opts.allowParent && body.parent_conversation_id !== undefined) { if (!isPoppyId(body.parent_conversation_id)) { throw new PoppyError('conversation_not_found', 'Unknown parent_conversation_id'); } parentConversationId = body.parent_conversation_id; } return { message: { id: message.id, sender: message.sender, ...(message.text !== undefined ? { text: message.text } : {}), ...(message.data !== undefined ? { data: message.data } : {}), ...(context !== undefined ? { context: { ...context } } : {}), }, ...(parentConversationId !== undefined ? { parentConversationId } : {}), }; } /** Stable JSON: object keys sorted, so equal content hashes equally whatever its key order. */ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (isRecord(value)) { return `{${Object.keys(value) .sort() .filter((key) => value[key] !== undefined) .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) .join(',')}}`; } return JSON.stringify(value) ?? 'null'; } /** * The fingerprint a retry is compared by (§7.3): the message's content and, for a first message, * the conversation it was addressed to. Unknown fields were dropped by the parser, so a retry that * adds one is still the same message. */ export function messageFingerprint(target, body) { return createHash('sha256') .update(canonical({ target, parent: body.parentConversationId ?? null, ...body.message })) .digest('base64url'); } /** * A request body that must be an empty JSON object (handoff, close) — anything else is tolerated: * extensions may add fields (§3.3). Only malformed JSON is refused. */ export function parseEmptyBody(raw) { if (raw.trim() === '') return; try { JSON.parse(raw); } catch { throw new PoppyError('invalid_request', 'The request body is not valid JSON'); } } //# sourceMappingURL=protocol.js.map