UNPKG

@adonis-agora/agent

Version:

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

750 lines • 30.1 kB
import { getToolName, isDataUIPart, isFileUIPart, isReasoningUIPart, isTextUIPart, isToolUIPart, } from 'ai'; import { readElicitationInput, validateElicitationAnswer, } from '../../../elicitation-input.js'; import { readUiActionText } from '../../../genui/actions.js'; import { coerceAnswer } from '../elicitation/answers.js'; import { describeToolCall, groupToolActivity, retriedCallIds, } from '../presentation/tool-activity.js'; import { readReasoningMs } from '../reasoning/timing.js'; /** * Walk a message's parts into renderable blocks, buffering consecutive tool parts into one run and * consecutive files into one strip. A `data-ui` part becomes a `ui` block. A * `data-approval-requested` part is metadata about a call — it is folded into that call's * `approval` and takes no position of its own. Other `data-*` parts are dropped rather than guessed * at, but they still terminate a tool run — their position in the transcript is meaningful even * when their content is not modelled here. */ export function buildTranscriptBlocks(message, options) { const blocks = []; let toolBuffer = []; let retrievalBuffer = []; let fileBuffer = []; let toolCounter = 0; let textCounter = 0; let fileCounter = 0; let reasoningCounter = 0; let sourcesCounter = 0; let elicitationCounter = 0; const approvals = readApprovals(message.parts ?? []); function flushTools() { if (toolBuffer.length === 0) { return; } const calls = toolBuffer.map((part) => buildToolCall(part, options.approval, approvalOf(approvals.get(part.toolCallId), part), options.toolCatalog)); const roots = nestToolCalls(calls); blocks.push({ kind: 'tools', key: `${message.id}-tools-${toolCounter++}`, parts: toolBuffer, calls, roots, activity: groupToolActivity(roots, { ...(options.toolCatalog !== undefined ? { catalog: options.toolCatalog } : {}), }), }); toolBuffer = []; } function flushSources() { if (retrievalBuffer.length === 0) { return; } blocks.push(buildSourcesBlock(`${message.id}-sources-${sourcesCounter++}`, retrievalBuffer)); retrievalBuffer = []; } function flushFiles() { if (fileBuffer.length === 0) { return; } blocks.push({ kind: 'files', key: `${message.id}-files-${fileCounter++}`, files: fileBuffer.map((part) => ({ url: part.url, mediaType: part.mediaType, filename: part.filename ?? null, isImage: part.mediaType.startsWith('image/'), })), }); fileBuffer = []; } function flushAll() { flushTools(); flushSources(); flushFiles(); } const retried = options.hideRetriedFailures === false ? new Set() : retriedCallIds((message.parts ?? []).filter(isToolUIPart)); const settledCalls = new Set(); for (const part of message.parts ?? []) { if (isToolUIPart(part) && (part.state === 'output-available' || part.state === 'output-error' || part.state === 'output-denied')) { settledCalls.add(part.toolCallId); } } for (const part of message.parts ?? []) { if (isToolUIPart(part)) { if (retried.has(part.toolCallId)) continue; const elicitation = options.elicitation; if (elicitation !== undefined) { const request = readElicitationRequest(part); if (request !== null) { flushAll(); blocks.push(buildElicitationBlock(`${message.id}-elicitation-${elicitationCounter++}`, part, request, elicitation)); continue; } } if (options.sources === true && readPassages(part) !== null) { flushTools(); retrievalBuffer.push(part); continue; } flushSources(); flushFiles(); toolBuffer.push(part); continue; } if (isFileUIPart(part)) { flushTools(); flushSources(); fileBuffer.push(part); continue; } if (part.type === APPROVAL_PART || part.type === SETTLED_PART) { continue; } flushAll(); if (isDataUIPart(part)) { const ui = part.type === UI_PART ? readUiComponent(part) : null; // A preview stands only while its call is being written: withdrawn (no props), or left by a // call that settled without replacing it, it is nothing to draw. if (ui?.partial === true && (Object.keys(ui.props).length === 0 || (ui.toolCallId !== null && settledCalls.has(ui.toolCallId)))) { continue; } if (ui !== null) { blocks.push({ kind: 'ui', key: `${message.id}-ui-${ui.id}`, ...ui }); } continue; } if (isTextUIPart(part)) { const uiAction = message.role === 'user' ? readUiActionText(part.text) : null; blocks.push({ kind: 'text', key: `${message.id}-text-${textCounter++}`, text: part.text, isStreaming: part.state === 'streaming', ...(uiAction !== null ? { uiAction } : {}), }); continue; } if (isReasoningUIPart(part)) { const key = `${message.id}-reasoning-${reasoningCounter++}`; const isStreaming = part.state === 'streaming'; blocks.push({ kind: 'reasoning', key, text: part.text, isStreaming, durationMs: readReasoningMs(part), isOpen: options.isReasoningOpen(key, isStreaming), toggle: (open) => options.toggleReasoning(key, open), }); } } flushAll(); return blocks; } /** * Fold a run of retrieval parts into one provenance block. Sources keep the order the retriever * returned them in — that order IS the ranking — and the query is taken from the first call that * recorded one. */ function buildSourcesBlock(key, parts) { const byId = new Map(); let query = null; let passageCount = 0; for (const part of parts) { query ??= readQuery(part); for (const passage of readPassages(part) ?? []) { passageCount++; const id = passage.source ?? passage.id; const existing = byId.get(id); if (existing) { existing.passageCount++; existing.topScore = Math.max(existing.topScore, passage.score); existing.passages.push(passage); continue; } byId.set(id, { id, label: passage.source ?? passage.id, passageCount: 1, topScore: passage.score, passages: [passage], }); } } return { kind: 'sources', key, query, sources: [...byId.values()], passageCount }; } /** A call whose output has landed one way or another is nobody's decision any more. */ function isSettled(state) { return state === 'output-available' || state === 'output-error' || state === 'output-denied'; } function toolKind(part) { const metadata = part.toolMetadata; return isRecord(metadata) && typeof metadata.toolKind === 'string' ? metadata.toolKind : undefined; } /** * An `action` tool's input lands and then nothing follows: the loop waits on a person between the * call and its execution. So a call classified `action` and stuck at `input-available` IS the * pending approval — there is no separate frame that says so. */ function isAwaitingApproval(part) { if (part.state === 'approval-requested') { return true; } if (part.state !== 'input-available') { return false; } // A question set parks the same way and is persisted as an action, but it is settled by // answering it, not by approving it. return toolKind(part) === 'action' && readElicitationRequest(part) === null; } function toolParentId(part) { const metadata = part.toolMetadata; return isRecord(metadata) && typeof metadata.parentId === 'string' ? metadata.parentId : null; } /** * Arrange a block's calls into a tree by `parentId`. A call whose parent is not in the block (or * that names itself, or would close a cycle) stays a root, so nothing a runner sends is ever lost * from the tree — at worst it is shown flat. */ function nestToolCalls(calls) { const byId = new Map(calls.map((call) => [call.toolCallId, call])); const roots = []; for (const call of calls) { const parent = call.parentId !== null ? byId.get(call.parentId) : undefined; if (parent === undefined || isAncestor(call, parent, byId)) { roots.push(call); continue; } parent.children.push(call); } return roots; } /** True when `candidate` is `call` itself or sits under it — attaching would close a cycle. */ function isAncestor(call, candidate, byId) { const seen = new Set(); let current = candidate; while (current !== undefined && !seen.has(current.toolCallId)) { if (current.toolCallId === call.toolCallId) { return true; } seen.add(current.toolCallId); current = current.parentId !== null ? byId.get(current.parentId) : undefined; } return false; } function buildToolCall(part, options, approval, catalog) { const toolCallId = part.toolCallId; const awaiting = approval?.target?.kind === 'proposal' ? approval.status === 'pending' : isAwaitingApproval(part); // Per decision, not per call: the two are never in flight together, and a surface that read one // flag for both would report the refusal it is carrying out as an approval in progress. const sending = awaiting ? (options?.submitting(toolCallId) ?? null) : null; return { part, toolCallId, name: getToolName(part), toolKind: toolKind(part) ?? null, parentId: toolParentId(part), children: [], approval, description: proposalDescription(describeToolCall(part, catalog, approval?.confirmation === undefined ? {} : { confirmation: approval.confirmation }), approval), isAwaitingApproval: awaiting, approve: { available: awaiting && options?.canApprove === true, isSubmitting: sending === 'approve', // Read by field, not passed through: `onClick={call.approve.run}` hands this a click event, // and a host's handler must not receive that as its options. run: (approveOptions) => approval?.target === undefined ? approveOptions?.remember === true ? options?.approve(toolCallId, { remember: true }) : options?.approve(toolCallId) : options?.approve(toolCallId, approveOptions?.remember === true ? { remember: true } : undefined, approval.target), }, reject: { available: awaiting && options?.canReject === true, isSubmitting: sending === 'reject', run: () => approval?.target === undefined ? options?.reject(toolCallId) : options?.reject(toolCallId, approval.target), }, error: options?.errorOf(toolCallId) ?? null, errorCode: options?.errorCodeOf?.(toolCallId) ?? null, }; } /** * The question set a tool call carries, or `null` for any other tool. Read from the call's INPUT * and by shape rather than by tool name: the same set is authored by an agent's intake and by the * model's own `ask`, it replays from the store under whatever name the row holds, and a client * that learned to recognise one name would render only half of them. */ function readElicitationRequest(part) { const input = part.input; if (!isRecord(input) || !Array.isArray(input.questions) || input.questions.length === 0) { return null; } const questions = []; for (const candidate of input.questions) { if (!isRecord(candidate) || typeof candidate.id !== 'string' || typeof candidate.prompt !== 'string') { return null; } // A typed question may carry no options; anything else is a pick and must offer some. const input = readElicitationInput(candidate.input) ?? null; const rawOptions = Array.isArray(candidate.options) ? candidate.options : []; if (rawOptions.length === 0 && (input === null || input.type === 'select')) { return null; } const options = []; for (const option of rawOptions) { if (!isRecord(option) || typeof option.value !== 'string' || typeof option.label !== 'string') { return null; } options.push({ value: option.value, label: option.label, hotkey: typeof option.hotkey === 'string' ? option.hotkey : null, }); } questions.push({ id: candidate.id, prompt: candidate.prompt, description: typeof candidate.description === 'string' ? candidate.description : null, input, multiple: candidate.multiple === true, allowFreeText: candidate.allowFreeText === true, defaults: Array.isArray(candidate.defaults) ? candidate.defaults.filter((value) => typeof value === 'string') : [], options, }); } return { preamble: typeof input.preamble === 'string' ? input.preamble : null, questions, }; } /** What the run recorded for a settled question set, or `null` when it settled as something else. */ function readElicitationOutcome(part) { const output = part.output; if (!isRecord(output) || !isRecord(output.answers) || typeof output.skipped !== 'boolean') { return null; } const answers = {}; for (const [id, values] of Object.entries(output.answers)) { answers[id] = Array.isArray(values) ? values.filter((value) => typeof value === 'string') : []; } return { answers, skipped: output.skipped, defaulted: Array.isArray(output.defaulted) ? output.defaulted.filter((id) => typeof id === 'string') : [], summary: typeof output.summary === 'string' ? output.summary : null, answeredBy: typeof output.answeredBy === 'string' ? output.answeredBy : null, answeredVia: typeof output.answeredVia === 'string' ? output.answeredVia : null, }; } function buildElicitationBlock(key, part, request, options) { const toolCallId = part.toolCallId; const isPending = !isSettled(part.state); const outcome = isPending ? null : readElicitationOutcome(part); const questions = request.questions.map((question, index) => { const picked = isPending ? options.picked(toolCallId, question.id) : undefined; const selected = picked ?? outcome?.answers[question.id] ?? question.defaults; const pick = (values) => options.pick(toolCallId, question.id, values); // The same rules the answer route applies, over the same question shape core reads. const error = isPending ? validateElicitationAnswer({ id: question.id, prompt: question.prompt, options: question.options.map(({ value, label }) => ({ value, label })), ...(question.input !== null ? { input: question.input } : {}), ...(question.multiple ? { multiple: true } : {}), ...(question.allowFreeText ? { allowFreeText: true } : {}), }, selected) : null; return { id: question.id, prompt: question.prompt, description: question.description, input: question.input, multiple: question.multiple, position: index + 1, selected, value: selected[0] ?? '', error, setValue: (raw) => { if (isPending) { pick(coerceAnswer(question, raw)); } }, isPristine: isPending ? picked === undefined : (outcome?.defaulted.includes(question.id) ?? true), options: question.options.map((option) => ({ value: option.value, label: option.label, hotkey: option.hotkey, isSelected: selected.includes(option.value), isDefault: question.defaults.includes(option.value), select: () => { if (!isPending) { return; } if (!question.multiple) { pick([option.value]); return; } pick(selected.includes(option.value) ? selected.filter((value) => value !== option.value) : [...selected, option.value]); }, })), }; }); const sending = isPending ? options.submitting(toolCallId) : null; return { kind: 'elicitation', key, toolCallId, preamble: request.preamble, questions, questionCount: questions.length, isPending, isValid: questions.every((question) => question.error === null), outcome, error: options.errorOf(toolCallId), errorCode: options.errorCodeOf?.(toolCallId) ?? null, answer: { available: isPending && options.canAnswer, isSubmitting: sending === 'answer', run: () => options.answer(toolCallId), }, skip: { available: isPending && options.canSkip, isSubmitting: sending === 'skip', run: () => options.skip(toolCallId), }, }; } const UI_PART = 'data-ui'; const APPROVAL_PART = 'data-approval-requested'; const SETTLED_PART = 'data-approval-settled'; const SETTLED_STATUSES = ['approved', 'rejected', 'expired', 'superseded']; /** The approval metadata on a message, by call id. The latest frame of each kind for a call wins. */ function readApprovals(parts) { const out = new Map(); const entry = (id) => { let found = out.get(id); if (found === undefined) { found = { approver: null, expiresAt: null, reason: null, status: null, remember: false, decidedBy: null, decidedVia: null, decisionReason: null, }; out.set(id, found); } return found; }; for (const part of parts) { if (part.type !== APPROVAL_PART && part.type !== SETTLED_PART && part.type !== 'data-action-proposal') { continue; } const data = part.data; if (!isRecord(data) || typeof data.id !== 'string') { continue; } const approval = entry(data.id); if (part.type === APPROVAL_PART || part.type === 'data-action-proposal') { if (isRecord(data.target) && data.target.kind === 'proposal' && typeof data.target.proposalId === 'string') { approval.target = { kind: 'proposal', proposalId: data.target.proposalId, ...(typeof data.target.threadId === 'string' ? { threadId: data.target.threadId } : {}), }; } if (part.type === 'data-action-proposal') { if (typeof data.status === 'string' && ['pending', 'approved', 'rejected', 'expired', 'superseded'].includes(data.status)) approval.status = data.status === 'pending' ? null : data.status; if (data.executionStatus === null || data.executionStatus === 'queued' || data.executionStatus === 'executing' || data.executionStatus === 'succeeded' || data.executionStatus === 'failed') approval.executionStatus = data.executionStatus; approval.remember = data.remember === true; approval.decidedBy = typeof data.decidedBy === 'string' ? data.decidedBy : null; approval.decidedVia = typeof data.decidedVia === 'string' ? data.decidedVia : null; } if (typeof data.approver !== 'string') { continue; } approval.approver = data.approver; approval.expiresAt = typeof data.expiresAt === 'string' ? data.expiresAt : null; if (isRecord(data.confirmation) && typeof data.confirmation.title === 'string' && typeof data.confirmation.verb === 'string' && (data.confirmation.detail === undefined || typeof data.confirmation.detail === 'string')) { approval.confirmation = { title: data.confirmation.title, verb: data.confirmation.verb, ...(typeof data.confirmation.detail === 'string' ? { detail: data.confirmation.detail } : {}), }; } approval.reason = typeof data.reason === 'string' ? data.reason : null; continue; } if (typeof data.status !== 'string' || !SETTLED_STATUSES.includes(data.status)) { continue; } approval.status = data.status; if (approval.approver === null && typeof data.approver === 'string') { approval.approver = data.approver; } approval.remember = data.remember === true; approval.decidedBy = typeof data.decidedBy === 'string' ? data.decidedBy : null; approval.decidedVia = typeof data.decidedVia === 'string' ? data.decidedVia : null; approval.decisionReason = typeof data.reason === 'string' ? data.reason : null; } return out; } /** * One call's approval: its parts, with the status completed from the call's own state when no * settlement was streamed. `null` when nothing on the message says who decides. */ function approvalOf(parts, call) { if (parts === undefined || parts.approver === null) { return null; } return { approver: parts.approver, ...(parts.target === undefined ? {} : { target: parts.target }), ...(parts.executionStatus === undefined ? {} : { executionStatus: parts.executionStatus }), ...(parts.confirmation !== undefined ? { confirmation: parts.confirmation } : {}), expiresAt: parts.expiresAt, reason: parts.reason, status: parts.status ?? (parts.target?.kind === 'proposal' ? 'pending' : statusFromPart(call)), remember: parts.remember, decidedBy: parts.decidedBy, decidedVia: parts.decidedVia, decisionReason: parts.decisionReason, }; } function statusFromPart(call) { if (call.state === 'output-denied') { return 'rejected'; } if (call.state === 'output-available' || call.state === 'output-error') { return 'approved'; } return 'pending'; } /** A pushed component, or `null` when the part does not carry one this model can address. */ function readUiComponent(part) { const data = part.data; if (!isRecord(data) || typeof data.component !== 'string') { return null; } const id = typeof data.id === 'string' ? data.id : part.id; if (id === undefined) { return null; } return { id, component: data.component, props: isRecord(data.props) ? data.props : {}, version: typeof data.version === 'number' ? data.version : null, ...(isRecord(data.componentVersions) && Object.values(data.componentVersions).every((version) => typeof version === 'number' && Number.isSafeInteger(version) && version > 0) ? { componentVersions: { ...data.componentVersions } } : {}), ...(typeof data.fallbackText === 'string' ? { fallbackText: data.fallbackText } : {}), toolCallId: typeof data.toolCallId === 'string' ? data.toolCallId : null, ...(data.partial === true ? { partial: true } : {}), }; } function isRecord(value) { return typeof value === 'object' && value !== null; } function readQuery(part) { const input = part.input; if (!isRecord(input) || typeof input.query !== 'string') { return null; } return input.query; } /** * The passages on a settled retrieval tool call, or `null` for any other tool. A call still in * flight, or one whose output is not a non-empty list of `{ id, text }` passages, is not provenance * yet and stays an ordinary tool part. */ function readPassages(part) { const output = part.output; if (!isRecord(output) || !Array.isArray(output.passages) || output.passages.length === 0) { return null; } const passages = []; for (const candidate of output.passages) { if (!isRecord(candidate) || typeof candidate.id !== 'string' || typeof candidate.text !== 'string') { return null; } passages.push({ id: candidate.id, text: candidate.text, score: typeof candidate.score === 'number' ? candidate.score : 0, ...(typeof candidate.source === 'string' ? { source: candidate.source } : {}), ...(isRecord(candidate.metadata) ? { metadata: candidate.metadata } : {}), }); } return passages; } /** The message's prose, joined for the clipboard. Reasoning is excluded — it is not the answer. */ export function extractMessageText(parts) { const out = []; for (const part of parts ?? []) { if (isTextUIPart(part)) { out.push(part.text); } } return out.join('\n\n').trim(); } export function describeUsage(usage) { const totalTokens = usage.inputTokens + usage.outputTokens; return { ...usage, totalTokens, costLabel: formatCostUsd(usage.costUsd), tokensLabel: formatTokensShort(totalTokens), }; } /** `null` for an unparseable stamp, so a bad `created_at` renders nothing instead of "Invalid Date". */ export function describeTimestamp(createdAt) { if (createdAt == null) { return null; } const date = new Date(createdAt); if (Number.isNaN(date.getTime())) { return null; } return { iso: createdAt, date, relative: formatRelativeTime(date), absolute: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }), }; } const RELATIVE_TIME = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'narrow' }); const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; /** * "just now" within ±10s (server `created_at` can land a few ms ahead of the client clock), a * relative phrase up to a week, then a calendar date. Uses Intl.RelativeTimeFormat — no date-fns. */ export function formatRelativeTime(date) { const diffMs = Date.now() - date.getTime(); if (Math.abs(diffMs) < 10_000) { return 'just now'; } if (diffMs > ONE_WEEK_MS) { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } const minutes = Math.round(-diffMs / 60_000); if (Math.abs(minutes) < 60) { return RELATIVE_TIME.format(minutes, 'minute'); } const hours = Math.round(-diffMs / 3_600_000); if (Math.abs(hours) < 24) { return RELATIVE_TIME.format(hours, 'hour'); } return RELATIVE_TIME.format(Math.round(-diffMs / 86_400_000), 'day'); } function formatTokensShort(n) { if (n < 1_000) { return `${n} tokens`; } if (n < 10_000) { return `${(n / 1_000).toFixed(1)}k tokens`; } return `${Math.round(n / 1_000)}k tokens`; } function formatCostUsd(cost) { if (cost === null) { return '—'; } if (cost === 0) { return '$0'; } if (cost < 0.01) { return `$${cost.toFixed(4)}`; } if (cost < 1) { return `$${cost.toFixed(3)}`; } return `$${cost.toFixed(2)}`; } function proposalDescription(description, approval) { if (approval?.target?.kind !== 'proposal') return description; const status = approval.status === 'pending' ? 'awaiting-approval' : approval.status === 'approved' ? (approval.executionStatus ?? 'queued') : 'denied'; const phrase = status === 'awaiting-approval' ? 'Awaiting approval' : status === 'queued' ? 'Queued' : status === 'executing' ? 'Executing' : status === 'succeeded' ? 'Succeeded' : status === 'failed' ? 'Failed' : approval.status === 'expired' ? 'Expired' : approval.status === 'superseded' ? 'Replaced' : 'Rejected'; return { ...description, status, phrase, result: null }; } //# sourceMappingURL=model.js.map