UNPKG

@adonis-agora/agent

Version:

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

574 lines • 25.6 kB
/** The ids of an AG-UI run this consumer invented, when the send did not name a thread. */ function newId() { return globalThis.crypto.randomUUID(); } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } async function defaultRefusal(response) { try { const body = (await response.json()); if (typeof body.message === 'string' && body.message.length > 0) return new Error(body.message); } catch { // not JSON: fall through to the status } return new Error(`The agent refused the request (${response.status}).`); } /** The provider name `agUiAdapter()` reads a `file` handle to one of its own uploads by. */ const AG_UI_STAGED_MEDIA_PROVIDER = 'agora'; function mediaKind(contentType) { if (contentType?.startsWith('image/')) return 'image'; if (contentType?.startsWith('audio/')) return 'audio'; if (contentType?.startsWith('video/')) return 'video'; return 'document'; } /** The body's message, and its staged uploads as media parts by `file` handle. */ function defaultContent(body, files) { const message = typeof body.message === 'string' ? body.message : ''; const refs = Array.isArray(body.attachments) ? body.attachments.flatMap((ref) => isRecord(ref) && typeof ref.mediaId === 'string' && ref.mediaId.length > 0 ? [ref.mediaId] : []) : []; if (refs.length === 0) return message; const parts = message.length > 0 ? [{ type: 'text', text: message }] : []; for (const mediaId of refs) { const file = files.find((each) => each.mediaId === mediaId); parts.push({ type: mediaKind(file?.contentType), source: { type: 'file', provider: AG_UI_STAGED_MEDIA_PROVIDER, value: mediaId, ...(file?.contentType !== undefined ? { mimeType: file.contentType } : {}), }, ...(file?.name !== undefined ? { metadata: { filename: file.name } } : {}), }); } return parts; } function defaultForwardedProps(body) { const out = {}; for (const key of ['pageContext', 'agent', 'model', 'persona', 'uiCapabilities']) { if (body[key] !== undefined && body[key] !== null) out[key] = body[key]; } return Object.keys(out).length > 0 ? out : undefined; } /** Start a turn against an AG-UI producer; the stream comes back in this library's framing. */ export async function agUiChatStream(request, options) { const body = request.body; const threadId = typeof body.threadId === 'string' ? body.threadId : newId(); const files = request.files ?? []; let forwarded = (options.forwardedProps ?? defaultForwardedProps)(body); // Not a body field the app shapes: without it the producer would answer the question again. if (body.regenerate === true && (forwarded === undefined || isRecord(forwarded))) { forwarded = { ...forwarded, regenerate: true }; } const input = { threadId, runId: newId(), protocolVersion: '1.0', messages: [ { id: newId(), role: 'user', content: (options.content ?? defaultContent)(body, files), }, ], ...(forwarded !== undefined ? { forwardedProps: forwarded } : {}), }; const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); const response = await doFetch(options.url, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...options.headers, ...request.headers, }, body: JSON.stringify(input), ...(request.signal !== undefined ? { signal: request.signal } : {}), }); if (!response.ok || response.body === null) { throw await (options.refusal ?? defaultRefusal)(response); } const runId = response.headers.get('x-agent-run-id') ?? undefined; return { body: reframeAgUiStream(response.body, { threadId }), threadId, ...(runId !== undefined ? { runId } : {}), }; } /** * Re-frame an AG-UI event stream (SSE, one event per `data:`) in this library's stream protocol: * `event: meta`, `data:` frames of {@link AgentStreamEvent}, then `event: done` or `event: error`. * When the producer numbers its events with the run's own sequence (SSE `id:`, as `agUiAdapter()` * does), that number is passed on as `id:`-only blocks — the cursor the transport re-attaches to * the native stream with (`chat/:runId/stream?after=`). Exported for a backend that fetches the * stream itself. */ export function reframeAgUiStream(source, ids) { const encoder = new TextEncoder(); const decoder = new TextDecoder(); const reframer = createReframer(ids.threadId); const reader = source.getReader(); let buffer = ''; return new ReadableStream({ async pull(controller) { for (;;) { const { value, done } = await reader.read(); if (done) { // A stream that stopped without a terminal event is a truncated run: end it plainly, and // the transport reads the stored thread for the rest. const rest = reframer.end(); if (rest.length > 0) controller.enqueue(encoder.encode(rest)); controller.close(); return; } buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n'); let out = ''; let separator = buffer.indexOf('\n\n'); while (separator !== -1) { const block = buffer.slice(0, separator); buffer = buffer.slice(separator + 2); separator = buffer.indexOf('\n\n'); const lines = block.split('\n'); const idLine = lines.find((line) => line.startsWith('id:')); const id = idLine === undefined ? Number.NaN : Number(idLine.slice(3).trim()); const data = lines .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trimStart()) .join('\n'); if (data.length === 0) continue; let event; try { event = JSON.parse(data); } catch { continue; } if (isRecord(event) && typeof event.type === 'string') { out += reframer.push(event, Number.isSafeInteger(id) && id > 0 ? id : undefined); } if (reframer.closed) break; } if (out.length > 0) controller.enqueue(encoder.encode(out)); if (reframer.closed) { void reader.cancel().catch(() => undefined); controller.close(); return; } if (out.length > 0) return; } }, cancel(reason) { return reader.cancel(reason).catch(() => undefined); }, }); } function createReframer(threadId) { const activities = new Map(); let metaSent = false; const tools = new Map(); // `STEP_FINISHED` is held until the next event: the producer's `agora.step-usage` right after it // carries what the step spent. let stepFinished = false; // Interrupts the stream already carried as an approval card or a question set (by call id, and // the question sets themselves for one that belongs to no call). const carried = new Set(); const carriedRequests = new Set(); /** A text decision on an action proposal (no run): written at the run's end, with its text. */ let decision; const encodeFrame = (streamEvent) => `data: ${JSON.stringify(streamEvent)}\n\n`; const takeStep = (extra = {}) => { if (!stepFinished) return ''; stepFinished = false; return encodeFrame({ kind: 'step-finish', ...extra }); }; /** * The run's sequence number of the events being read, and the last one written out as a cursor. * Several AG-UI events stand for one event of the run, so the cursor (an `id:`-only SSE block the * transport records) moves only once every event of it was re-framed — re-attaching `?after=` it * then neither skips nor repeats anything. Frames carry no `id:` of their own: a count of * re-framed frames is not the run's sequence. */ let current; let written; const cursor = () => { if (current === undefined || current === written) return ''; written = current; return `${takeStep()}id: ${current}\n\n`; }; const state = { closed: false, end() { if (state.closed) return ''; return `${takeStep()}${cursor()}`; }, push(event, id) { if (state.closed) return ''; let out = ''; if (id !== undefined) { if (current !== undefined && id > current) out += cursor(); current = id; } const frame = (streamEvent) => { out += encodeFrame(streamEvent); }; const meta = (runId, thread) => { if (metaSent) return; metaSent = true; out += `event: meta\ndata: ${JSON.stringify({ runId: typeof runId === 'string' ? runId : '', threadId: typeof thread === 'string' ? thread : threadId, })}\n\n`; }; const flushStep = (extra = {}) => { out += takeStep(extra); }; if (stepFinished && event.type === 'CUSTOM' && event.name === 'agora.step-usage' && isRecord(event.value)) { const value = event.value; flushStep({ ...(isRecord(value.usage) ? { usage: value.usage } : {}), ...(typeof value.costUsd === 'number' || value.costUsd === null ? { costUsd: value.costUsd } : {}), ...(typeof value.reasoningMs === 'number' ? { reasoningMs: value.reasoningMs } : {}), ...(typeof value.model === 'string' ? { model: value.model } : {}), }); return out; } flushStep(); switch (event.type) { case 'RUN_STARTED': // `agora.run` (next) names the library's own run; wait a beat for it only if it follows // at once — otherwise the protocol's own ids do. pendingStart = { runId: event.runId, threadId: event.threadId }; return out; case 'CUSTOM': { const value = event.value; if (event.name === 'agora.run' && isRecord(value)) { meta(value.runId, pendingStart?.threadId ?? value.threadId); pendingStart = undefined; return out; } flushStart(); if (!isRecord(value)) return out; if (event.name === 'agora.ui') frame({ kind: 'ui', ...value }); else if (event.name === 'agora.title' && typeof value.title === 'string') { frame({ kind: 'title', title: value.title }); } else if (event.name === 'agora.queue') frame({ kind: 'queue', ...value }); else if (event.name === 'agora.approval-requested') { if (typeof value.id === 'string') carried.add(value.id); frame({ ...value, kind: 'approval-requested' }); } else if (event.name === 'agora.elicitation') { // The question block answers it by id (the call that asked); a producer that does not // say which leaves it to the interrupt. if (typeof value.id === 'string' && isRecord(value.request)) { carried.add(value.id); carriedRequests.add(JSON.stringify(value.request)); frame({ kind: 'elicitation', id: value.id, request: value.request }); } } else if (event.name === 'agora.approval-settled') { frame({ ...value, kind: 'approval-settled' }); } else if (event.name === 'agora.action-proposal-decision') { decision = { threadId: value.threadId, proposalDecision: value.proposalDecision, text: '', }; } return out; } default: flushStart(); } switch (event.type) { case 'TEXT_MESSAGE_CONTENT': case 'TEXT_MESSAGE_CHUNK': if (decision !== undefined && typeof event.delta === 'string') { decision.text += event.delta; } if (typeof event.delta === 'string' && event.delta.length > 0) { frame({ kind: 'text', text: event.delta }); } break; case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': if (typeof event.delta === 'string' && event.delta.length > 0) { frame({ kind: 'reasoning', text: event.delta }); } break; case 'STEP_STARTED': frame({ kind: 'step-start' }); break; case 'STEP_FINISHED': stepFinished = true; break; case 'TOOL_CALL_START': case 'TOOL_CALL_CHUNK': { const id = String(event.toolCallId ?? ''); if (id === '') break; if (!tools.has(id)) { const name = typeof event.toolCallName === 'string' ? event.toolCallName : ''; const toolKind = isRecord(event.metadata) && event.metadata['agora.toolKind'] === 'action' ? 'action' : 'read'; tools.set(id, { name, args: '', toolKind }); frame({ kind: 'tool-input-start', id, name, toolKind }); } if (event.type === 'TOOL_CALL_CHUNK' && typeof event.delta === 'string') { tools.get(id).args += event.delta; frame({ kind: 'tool-input-delta', id, delta: event.delta }); } break; } case 'TOOL_CALL_ARGS': { const call = tools.get(String(event.toolCallId)); if (call === undefined || typeof event.delta !== 'string') break; call.args += event.delta; frame({ kind: 'tool-input-delta', id: String(event.toolCallId), delta: event.delta }); break; } case 'TOOL_CALL_END': { const id = String(event.toolCallId); const call = tools.get(id); if (call === undefined) break; frame({ kind: 'tool-input-available', id, name: call.name, input: parseJson(call.args, {}), toolKind: call.toolKind, }); break; } case 'TOOL_CALL_RESULT': { const id = String(event.toolCallId); const content = contentText(event.content); const outcome = isRecord(event.metadata) ? event.metadata['agora.outcome'] : undefined; if (outcome === 'error') frame({ kind: 'tool-output-error', id, error: content }); else if (outcome === 'denied') frame({ kind: 'tool-output-denied', id, reason: content }); else frame({ kind: 'tool-output', id, output: parseJson(content, content) }); break; } case 'ACTIVITY_SNAPSHOT': case 'ACTIVITY_DELTA': { const messageId = typeof event.messageId === 'string' ? event.messageId : ''; if (messageId === '') break; const known = activities.get(messageId); const activityType = typeof event.activityType === 'string' ? event.activityType : (known?.activityType ?? ''); let content; if (event.type === 'ACTIVITY_SNAPSHOT') { // `replace: false` leaves an existing activity as it stands if (known !== undefined && event.replace === false) break; content = isRecord(event.content) ? event.content : {}; } else { // a delta for an activity nothing created is skipped, never fatal if (known === undefined || !Array.isArray(event.patch)) break; content = applyPatch(known.content, event.patch); if (content === undefined) break; } activities.set(messageId, { activityType, content }); frame({ kind: 'ui', id: `activity:${messageId}`, component: 'AgUiActivity', props: { activityType, content }, }); break; } case 'RUN_ERROR': state.closed = true; out += `event: error\ndata: ${JSON.stringify({ code: typeof event.code === 'string' ? event.code : 'run_failed', message: typeof event.message === 'string' ? event.message : 'The run failed.', })}\n\n`; break; case 'RUN_FINISHED': { const outcome = isRecord(event.outcome) ? event.outcome : undefined; if (decision !== undefined) { // What a native text decision answers with: a `data-proposal-decision` part. frame({ kind: 'proposal-decision', ...decision }); decision = undefined; } if (outcome?.type === 'cancelled') frame({ kind: 'cancelled' }); if (outcome?.type === 'interrupt' && Array.isArray(outcome.interrupts)) { // What the stream already showed as an approval card or a question block is answered // there; the rest is the app's to render. const interrupts = outcome.interrupts.filter((interrupt) => !isCarried(interrupt)); if (interrupts.length === 0) { // Parked on what the chat shows: the run is not over. End without `done`, at the // cursor, so the transport re-attaches on the native route and the rest of the run // (after a decision over the `tool-call` routes) streams into the same message. out += cursor(); state.closed = true; break; } frame({ kind: 'ui', id: `ag-ui:interrupt:${String(event.runId ?? '')}`, component: 'AgUiInterrupt', props: { interrupts }, }); } out += cursor(); state.closed = true; out += 'event: done\ndata: {}\n\n'; break; } default: // Everything else (message and span brackets, state, activity, raw) carries nothing this // framing does not already say. break; } return out; function isCarried(interrupt) { if (!isRecord(interrupt)) return false; if (typeof interrupt.toolCallId === 'string' && carried.has(interrupt.toolCallId)) { return true; } const request = isRecord(interrupt.metadata) ? interrupt.metadata['agora.request'] : undefined; return request !== undefined && carriedRequests.has(JSON.stringify(request)); } function flushStart() { if (pendingStart !== undefined) { meta(pendingStart.runId, pendingStart.threadId); pendingStart = undefined; } } }, }; let pendingStart; return state; } function parseJson(text, fallback) { if (text.trim().length === 0) return fallback; try { return JSON.parse(text); } catch { return fallback; } } /** A result's content as text: a string, or its text parts in order (other parts carry no text). */ function contentText(content) { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; return content .filter((part) => isRecord(part) && part.type === 'text' && typeof part.text === 'string') .map((part) => part.text) .join(''); } /** * RFC 6902 on a copy: `add`, `replace`, `remove` (and `test`, which fails the patch). `undefined` * when the patch does not apply — the activity then keeps its last good content until a snapshot. */ function applyPatch(target, patch) { const doc = structuredClone(target); for (const operation of patch) { if (!isRecord(operation) || typeof operation.path !== 'string') return undefined; const tokens = operation.path .split('/') .slice(1) .map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~')); const key = tokens.pop(); if (key === undefined) return undefined; let parent = doc; for (const token of tokens) { parent = Array.isArray(parent) ? parent[Number(token)] : isRecord(parent) ? parent[token] : undefined; if (parent === undefined) return undefined; } if (Array.isArray(parent)) { const index = key === '-' ? parent.length : Number(key); if (!Number.isInteger(index) || index < 0 || index > parent.length) return undefined; if (operation.op === 'add') parent.splice(index, 0, operation.value); else if (operation.op === 'replace' && index < parent.length) parent[index] = operation.value; else if (operation.op === 'remove' && index < parent.length) parent.splice(index, 1); else if (operation.op === 'test') { if (JSON.stringify(parent[index]) !== JSON.stringify(operation.value)) return undefined; } else return undefined; } else if (isRecord(parent)) { if (operation.op === 'add' || (operation.op === 'replace' && key in parent)) { parent[key] = operation.value; } else if (operation.op === 'remove' && key in parent) delete parent[key]; else if (operation.op === 'test') { if (JSON.stringify(parent[key]) !== JSON.stringify(operation.value)) return undefined; } else return undefined; } else return undefined; } return doc; } //# sourceMappingURL=ag-ui-backend.js.map