UNPKG

@adonis-agora/agent

Version:

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

116 lines • 6.21 kB
import { storedMessageToUiMessage } from './stored-message-to-ui-message.js'; /** * Convert a thread's full `StoredMessage[]` history into `UIMessage[]`, merging consecutive * assistant rows (no user message between them) into ONE `UIMessage` per turn. * * Why: the agent loop persists one row per model iteration (see `agent-loop.ts`'s per-step * `persist:assistant:${i}`) — a turn with tool calls appends "I'll check that..." + tool calls, * then a separate final-answer row. `storedMessageToUiMessage` maps 1:1, so a reloaded thread * renders 2-3 assistant bubbles (each with its own cost/tokens footer) for what the LIVE stream * renders as one continuous response (one `UIMessage`, stepped internally — see * `AgentChatTransport.toChunkStream`'s `start-step`/`finish-step` bracketing). This closes that gap * for replayed history. * * Merge shape: each row is mapped via `storedMessageToUiMessage` (unchanged — same text-then-tool * part ordering per row) and the parts are concatenated in row order, so the grouped message's parts * read as `[row1 text?, row1 tools?, row2 text?, row2 tools?, ...]` — exactly the sequence the live * transport streams for the same turn. The group's `id` is the LAST row's id (the final-answer row — * it's the one carrying `followUps`, and what a host keys turn-level actions like "fork" or * "regenerate" on). Non-assistant rows (user/system) pass through `storedMessageToUiMessage` * unchanged, one row → one message, same as today. * * A lone assistant row between two user rows (the common case: no tool calls, or already a single * iteration) has nothing to merge with, so it comes back byte-for-byte identical to calling * `storedMessageToUiMessage` directly — no `metadata` is added for that case either, preserving * exact backward compatibility for hosts that read stored usage some other way. * * Only an ACTUAL merge (2+ rows) attaches `metadata.usage` — the {@link AggregatedTurnUsage} summed * across the merged rows' `usage` (rows without a `usage` at all, e.g. an older/test fixture, are * skipped rather than treated as zero-and-priced). * * Deliberately NOT reproduced: the live transport's `{type: 'step-start'}` UI parts (one per model * call, emitted by the AI SDK for every `start-step` chunk — see `repro-real-frames.spec.ts` for a * captured wire dump). Those are structural brackets `MessageItem`'s `renderBlocks` never renders * (it draws only text, reasoning, files and tool blocks), and `storedMessageToUiMessage` has never * emitted them for a single row either — inventing them here would make a merged turn diverge from * a same-shaped unmerged one instead of converging on it. "Indistinguishable from the live stream" * is scoped to what actually renders: the text/tool-call content in step order, not the step * brackets around it. */ export function storedThreadToUiMessages(messages) { const turns = []; for (const message of messages) { const currentTurn = turns.at(-1); const previousRow = currentTurn?.at(-1); if (message.role === 'assistant' && previousRow?.role === 'assistant' && currentTurn && sameRun(previousRow, message)) { currentTurn.push(message); } else { turns.push([message]); } } return turns.map(mergeTurn); } /** * Two assistant rows belong to the same TURN, not merely to the same side of the conversation. * Consecutive is the right test only while one run at a time writes to a thread: a detached * sub-agent posts its answer whenever it lands, so a thread whose last row is an assistant reply can * gain another from a different run entirely — merged, that reads as the assistant having said both. * * Rows that carry no `runId` (written before it was recorded, or by a host outside a run) merge as * they always did: there is nothing to tell them apart with, and splitting on absence would * un-merge every old thread. */ function sameRun(previous, next) { if (previous.actionProposalOutcome !== undefined || next.actionProposalOutcome !== undefined) return false; return previous.runId === undefined || next.runId === undefined || previous.runId === next.runId; } function mergeTurn(rows) { const lastRow = rows.at(-1); if (lastRow === undefined) { // Unreachable: `storedThreadToUiMessages` never pushes an empty turn. throw new Error('storedThreadToUiMessages: encountered an empty turn'); } if (rows.length === 1) { return storedMessageToUiMessage(lastRow); } const parts = rows.flatMap((row) => storedMessageToUiMessage(row).parts); const usage = sumUsage(rows); // A turn is rated through its last row — the id this message carries. const feedback = lastRow.feedback; const createdAt = rows[0]?.createdAt; const metadata = { // Host facts of every row, a later row's winning (the turn's final state), under the library's. ...Object.assign({}, ...rows.map((row) => row.metadata ?? {})), ...(usage !== undefined ? { usage } : {}), ...(feedback !== undefined ? { feedback } : {}), ...(createdAt ? { createdAt } : {}), ...(lastRow.agentName !== undefined ? { agentName: lastRow.agentName } : {}), ...(lastRow.persona !== undefined ? { persona: lastRow.persona } : {}), }; return { id: lastRow.id, role: lastRow.role, parts, ...(Object.keys(metadata).length > 0 ? { metadata } : {}), }; } function sumUsage(rows) { const usages = rows .map((row) => row.usage) .filter((usage) => usage !== undefined); if (usages.length === 0) { return undefined; } const allCostsUnknown = usages.every((usage) => usage.costUsd === null || usage.costUsd === undefined); return { inputTokens: usages.reduce((sum, usage) => sum + usage.inputTokens, 0), outputTokens: usages.reduce((sum, usage) => sum + usage.outputTokens, 0), costUsd: allCostsUnknown ? null : usages.reduce((sum, usage) => sum + (usage.costUsd ?? 0), 0), }; } //# sourceMappingURL=stored-thread-to-ui-messages.js.map