UNPKG

@adonis-agora/agent

Version:

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

253 lines • 10.3 kB
import { readA2uiAction } from '../../a2ui/core.js'; import { readUiAction } from '../../genui/actions.js'; import { validateUiCapabilities } from '../../genui/capabilities.js'; import { decodeInterruptId } from './interrupt-id.js'; function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Read a request body as a `RunAgentInput`, or say why it is not one. * * Only what this producer acts on is checked, and a KNOWN member with a value the schema rejects is * malformed (the run never starts). A member it does not recognise is not an error — the protocol's * asymmetry: a newer consumer's input must not bounce off an older producer. */ export function parseRunInput(body) { if (!isRecord(body)) return 'the body must be a RunAgentInput object'; if (typeof body.threadId !== 'string' || body.threadId.length === 0) { return 'threadId must be a non-empty string'; } if (body.threadId.length > 255) return 'threadId must be at most 255 characters'; if (typeof body.runId !== 'string' || body.runId.length === 0) { return 'runId must be a non-empty string'; } if (!Array.isArray(body.messages)) return 'messages must be an array'; for (const message of body.messages) { if (!isRecord(message) || typeof message.role !== 'string') { return 'each message must be an object with a role'; } } if (body.resume !== undefined) { if (!Array.isArray(body.resume)) return 'resume must be an array'; for (const entry of body.resume) { if (!isRecord(entry) || typeof entry.interruptId !== 'string' || (entry.status !== 'resolved' && entry.status !== 'cancelled')) { return "each resume entry needs an interruptId and a status of 'resolved' or 'cancelled'"; } } } if (body.context !== undefined && !Array.isArray(body.context)) { return 'context must be an array'; } if (isRecord(body.forwardedProps) && Object.hasOwn(body.forwardedProps, 'uiCapabilities')) { try { validateUiCapabilities(body.forwardedProps.uiCapabilities); } catch { return 'forwardedProps.uiCapabilities must be valid UI capabilities'; } } return body; } /** * The `provider` of a `file` source naming an upload this agent already staged * (`POST <path>/attachments`): `{ type: 'file', provider: 'agora', value: <mediaId> }`. */ export const AG_UI_STAGED_MEDIA_PROVIDER = 'agora'; const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf', 'text/plain': 'txt', }; function filenameOf(part, index, contentType) { const metadata = part.metadata; if (isRecord(metadata)) { for (const key of ['filename', 'name', 'title']) { const value = metadata[key]; if (typeof value === 'string' && value.trim().length > 0) return value.trim().slice(0, 200); } } const extension = EXTENSIONS[contentType]; return `${part.type}-${index + 1}${extension !== undefined ? `.${extension}` : ''}`; } /** * The turn a run answers: the LAST user message of the input. The library keeps each thread's * history itself, so the earlier messages the consumer restates are not read back in — what the * agent resumes from is what it stored. * * Multimodal parts: text parts are the message's text; a media part carried inline (`data`) becomes * an attachment, and so does a `file` handle naming an upload this agent staged * ({@link AG_UI_STAGED_MEDIA_PROVIDER}). A part by `url` or by any other provider's `file` handle * is dropped and said so — this producer hands the model only bytes it staged itself, never an * address a caller supplied. */ export function readUserTurn(messages) { let last; for (let index = messages.length - 1; index >= 0; index -= 1) { if (messages[index]?.role === 'user') { last = messages[index]; break; } } if (last === undefined) return null; if (typeof last.content === 'string') { return { text: last.content, media: [], staged: [], dropped: [] }; } if (!Array.isArray(last.content)) return { text: '', media: [], staged: [], dropped: [] }; const texts = []; const media = []; const staged = []; const dropped = []; last.content.forEach((part, index) => { if (!isRecord(part)) return; if (part.type === 'text') { if (typeof part.text === 'string') texts.push(part.text); return; } if (part.type !== 'image' && part.type !== 'audio' && part.type !== 'video' && part.type !== 'document') { return; } const source = part.source; if (isRecord(source) && source.type === 'file' && source.provider === AG_UI_STAGED_MEDIA_PROVIDER && typeof source.value === 'string' && source.value.length > 0) { staged.push({ mediaId: source.value }); return; } if (!isRecord(source) || source.type !== 'data') { const how = isRecord(source) && typeof source.type === 'string' ? source.type : 'unknown'; dropped.push(`The ${part.type} part ${index + 1} was not used: only inline data is accepted, not a ${how} source.`); return; } if (typeof source.value !== 'string' || typeof source.mimeType !== 'string') { dropped.push(`The ${part.type} part ${index + 1} was not used: its inline data is malformed.`); return; } const data = Buffer.from(source.value, 'base64'); if (data.length === 0) { dropped.push(`The ${part.type} part ${index + 1} was not used: it carries no bytes.`); return; } const contentType = source.mimeType.toLowerCase(); media.push({ kind: part.type, contentType, data, filename: filenameOf(part, index, contentType), }); }); return { text: texts.join('\n'), media, staged, dropped }; } /** `context` entries as the library's page context carries them, or `undefined` for none. */ export function readContext(context) { if (context === undefined) return undefined; const entries = context.filter((entry) => isRecord(entry) && typeof entry.description === 'string' && typeof entry.value === 'string'); return entries.length > 0 ? entries : undefined; } export function readForwardedProps(forwarded) { if (!isRecord(forwarded)) return {}; const pick = (key) => { const value = forwarded[key]; return typeof value === 'string' && value.length > 0 ? value : undefined; }; const agent = pick('agent'); const model = pick('model'); const persona = pick('persona'); return { ...(Object.hasOwn(forwarded, 'uiCapabilities') ? { uiCapabilities: validateUiCapabilities(forwarded.uiCapabilities) } : {}), ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), ...(persona !== undefined ? { persona } : {}), ...(forwarded.regenerate === true ? { regenerate: true } : {}), ...(isRecord(forwarded.pageContext) ? { pageContext: forwarded.pageContext } : {}), ...(forwarded.uiAction !== undefined ? { uiAction: readUiAction(forwarded.uiAction) } : isRecord(forwarded.a2uiAction) ? { uiAction: readA2uiAction(forwarded.a2uiAction) } : {}), }; } /** * Sort a resume list into what it answers. Every recognised entry must continue the SAME library * run from the SAME point — they are the interrupts of one `RUN_FINISHED` — and a list that does not * is malformed (a string says why). */ export function planResume(resume) { const decisions = []; const unrecognised = []; for (const entry of resume) { const address = decodeInterruptId(entry.interruptId); if (address === null) { unrecognised.push(entry.interruptId); continue; } decisions.push({ address, entry }); } const first = decisions[0]; if (first === undefined) return { decisions, unrecognised }; for (const { address } of decisions) { if (address.stream !== first.address.stream || address.position !== first.address.position) { return 'the resume entries answer interrupts of different runs'; } } const seen = new Set(); for (const { address } of decisions) { if (seen.has(address.toolCallId)) return 'an interrupt is answered twice'; seen.add(address.toolCallId); } return { decisions, unrecognised }; } /** `{ approved, reason?, remember? }` out of an approval's resume payload. A bare boolean is accepted. */ export function readApprovalPayload(payload) { if (typeof payload === 'boolean') return { approved: payload }; if (!isRecord(payload) || typeof payload.approved !== 'boolean') return null; return { approved: payload.approved, ...(typeof payload.reason === 'string' ? { reason: payload.reason } : {}), ...(typeof payload.remember === 'boolean' ? { remember: payload.remember } : {}), }; } /** `questionId → string[]` out of an elicitation's resume payload (`{ answers }` or the map itself). */ export function readAnswersPayload(payload) { const source = isRecord(payload) && isRecord(payload.answers) ? payload.answers : payload; if (!isRecord(source)) return null; const answers = {}; for (const [key, value] of Object.entries(source)) { if (typeof value === 'string') answers[key] = [value]; else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { answers[key] = value; } else return null; } return answers; } //# sourceMappingURL=input.js.map