UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

529 lines (496 loc) 15.8 kB
import type { AssistantMessage, InputContent, ReasoningMessage, SystemMessage, ToolCall, ToolMessage, UserMessage, } from '@ag-ui/core' import type { ContentPart, MessagePart, ModelMessage, StructuredOutputPart, TanStackMessageMetadata, UIMessage, UIResourcePart, } from '../types' import type { MetadataRecord } from './merge-metadata' import { tanstackMetadata } from './merge-metadata' import { normalizeToolResult } from './tool-result' import { coerceCreatedAt, modelMessageToUIMessage, } from '../activities/chat/messages' type WithMetadata<T> = T & { metadata?: MetadataRecord } type WireSystemMessage = WithMetadata<SystemMessage> type WireUserMessage = WithMetadata<UserMessage> type WireAssistantMessage = WithMetadata<AssistantMessage> type WireToolMessage = WithMetadata< ToolMessage & { name?: string } > type WireReasoningMessage = WithMetadata<ReasoningMessage> function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) } function rebuiltToolMetadata( metadata: unknown, createdAt: unknown, id: string | undefined, content: string | null | Array<ContentPart>, anchorOwnsUiResources = false, ): MetadataRecord | undefined { const source: MetadataRecord = isRecord(metadata) ? metadata : {} const tanstack = isRecord(source.tanstack) ? { ...source.tanstack } : {} if (anchorOwnsUiResources) delete tanstack.uiResources const date = coerceCreatedAt(createdAt) const toolResult: NonNullable<TanStackMessageMetadata['toolResult']> = { ...(id !== undefined ? { id } : {}), ...(date && { createdAt: date.toISOString() }), ...(Array.isArray(content) && { content }), } const result = { ...source, tanstack: { ...tanstack, toolResult }, } return Object.keys(result).length ? result : undefined } export type WireMessage = | WireSystemMessage | WireUserMessage | WireAssistantMessage | WireToolMessage | WireReasoningMessage /** * Serialize TanStack `UIMessage`s and `ModelMessage`s into the AG-UI * `RunAgentInput.messages` wire shape. Anchors are spec-only (`id`, `role`, * `name`, `content`, `toolCalls`, `metadata`). Tool results and thinking parts * on assistant messages are additionally emitted as fan-out * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict AG-UI * server consumers. Set `includeSnapshotStructuredOutput` to retain complete * structured-output metadata for UI snapshots. */ export function uiMessagesToWire( messages: Array<UIMessage | ModelMessage>, options?: { includeSnapshotStructuredOutput: boolean }, ): Array<WireMessage> { const wire: Array<WireMessage> = [] const usedWireIds = new Set<string>( messages.flatMap((message) => 'id' in message && message.id && message.role !== 'tool' ? [message.id] : [], ), ) const includeSnapshotStructuredOutput = options?.includeSnapshotStructuredOutput ?? false const assistantIds = new Set<string>() for (const msg of messages) { if (msg.role === 'assistant' && msg.id !== undefined) { assistantIds.add(msg.id) } } for (const msg of messages) { if (!('parts' in msg) && msg.role === 'tool' && msg.toolCallId) { const id = uniqueToolWireId( toolWireId(msg.id, msg.toolCallId, assistantIds), usedWireIds, ) const metadata = rebuiltToolMetadata( msg.metadata, msg.createdAt, msg.id, msg.content, ) wire.push({ role: 'tool', id, ...(msg.name !== undefined && { name: msg.name }), toolCallId: msg.toolCallId, content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), ...(msg.error !== undefined && { error: msg.error }), ...(metadata !== undefined && { metadata }), }) continue } const uiMessage: UIMessage = 'parts' in msg ? msg : modelMessageToUIMessage(msg, msg.id) const parts: ReadonlyArray<MessagePart> = uiMessage.parts usedWireIds.add(uiMessage.id) if (msg.role === 'system') { wire.push( toAnchor( uiMessage, 'system', { content: parts.length > 0 ? collectText(parts) : ((msg as { content?: string }).content ?? ''), }, parts, includeSnapshotStructuredOutput, ), ) continue } if (msg.role === 'user') { wire.push( toAnchor( uiMessage, 'user', { content: parts.length > 0 ? collectUserContent(parts) : ((msg as { content?: string }).content ?? ''), }, parts, includeSnapshotStructuredOutput, ), ) continue } // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs for (const part of parts) { if (part.type === 'thinking') { const reasoning: WireReasoningMessage = { role: 'reasoning', id: uniqueWireId(deriveReasoningId(uiMessage.id, part), usedWireIds), content: part.content, } if (part.signature) { reasoning.encryptedValue = part.signature } wire.push(reasoning) } } const text = collectText(parts) const toolCalls = collectToolCalls(parts) wire.push( toAnchor( uiMessage, 'assistant', { ...(text !== '' && { content: text }), ...(toolCalls && { toolCalls }), }, parts, includeSnapshotStructuredOutput, ), ) const explicitToolResults = new Set( parts.flatMap((part) => part.type === 'tool-result' ? [part.toolCallId] : [], ), ) for (const part of parts) { if (part.type === 'tool-result') { const id = uniqueToolWireId( part.id ?? deriveToolMessageId(part.toolCallId), usedWireIds, ) const metadata = rebuiltToolMetadata( part.metadata, part.createdAt, part.id, part.content, true, ) wire.push({ role: 'tool', id, toolCallId: part.toolCallId, ...(part.name !== undefined && { name: part.name }), content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content), ...(part.error !== undefined && { error: part.error }), ...(metadata !== undefined && { metadata }), }) } else if (part.type === 'tool-call') { const approved = part.approval?.approved if ( explicitToolResults.has(part.id) || (part.output === undefined && (part.state !== 'approval-responded' || approved === undefined)) ) { continue } const result = part.output !== undefined ? normalizeToolResult(part.output) : JSON.stringify({ approved, ...(approved && { pendingExecution: true }), message: approved ? 'User approved this action' : 'User denied this action', }) const content = typeof result === 'string' ? result : JSON.stringify(result) wire.push({ role: 'tool', id: uniqueToolWireId(deriveToolMessageId(part.id), usedWireIds), toolCallId: part.id, content, metadata: rebuiltToolMetadata( undefined, undefined, undefined, result, ), }) } } } return wire } function toAnchor( msg: UIMessage, role: 'system', extras: { content: string }, parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): WireSystemMessage function toAnchor( msg: UIMessage, role: 'user', extras: { content: string | Array<InputContent> }, parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): WireUserMessage function toAnchor( msg: UIMessage, role: 'assistant', extras: { content?: string toolCalls?: Array<ToolCall> }, parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): WireAssistantMessage function toAnchor( msg: UIMessage, role: UIMessage['role'], extras: { content?: string | Array<InputContent> toolCalls?: Array<ToolCall> }, parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): WireSystemMessage | WireUserMessage | WireAssistantMessage { const metadata = messageMetadata(msg, parts, includeSnapshotStructuredOutput) const base = { id: msg.id, ...(msg.name !== undefined && { name: msg.name }), ...(metadata !== undefined && { metadata }), } if (role === 'system') { return { ...base, role, content: String(extras.content ?? '') } } if (role === 'user') { return { ...base, role, content: extras.content ?? '' } } return { ...base, role, ...(typeof extras.content === 'string' && { content: extras.content }), ...(extras.toolCalls !== undefined && { toolCalls: extras.toolCalls }), } } function messageMetadata( msg: UIMessage, parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): MetadataRecord | undefined { const base: MetadataRecord = { ...(msg.metadata ?? {}) } const previousTanstack = tanstackMetadata(msg) const tanstack: TanStackMessageMetadata = {} if (previousTanstack?.model !== undefined) tanstack.model = previousTanstack.model if (previousTanstack?.signature !== undefined) tanstack.signature = previousTanstack.signature const createdAt = coerceCreatedAt(msg.createdAt) if (createdAt !== undefined) tanstack.createdAt = createdAt.toISOString() const structuredOutput = serializedStructuredOutput( parts, includeSnapshotStructuredOutput, ) if (structuredOutput) tanstack.structuredOutput = structuredOutput const toolCallMetadata: Record<string, unknown> = {} for (const part of parts) { if (part.type === 'tool-call' && part.metadata !== undefined) { toolCallMetadata[part.id] = part.metadata } } if (Object.keys(toolCallMetadata).length > 0) { tanstack.toolCallMetadata = toolCallMetadata } const uiResources = parts.filter( (p): p is UIResourcePart => p.type === 'ui-resource', ) if (uiResources.length > 0) tanstack.uiResources = uiResources if (Object.keys(tanstack).length > 0) base.tanstack = tanstack else delete base.tanstack return Object.keys(base).length > 0 ? base : undefined } function serializedStructuredOutput( parts: ReadonlyArray<MessagePart>, includeSnapshotStructuredOutput: boolean, ): TanStackMessageMetadata['structuredOutput'] | undefined { for (const p of parts) { if ( p.type === 'structured-output' && (includeSnapshotStructuredOutput || p.status !== 'complete') ) { return structuredOutputMetadata(p, includeSnapshotStructuredOutput) } } return undefined } function structuredOutputMetadata( part: StructuredOutputPart, includeSnapshotStructuredOutput: boolean, ): NonNullable<TanStackMessageMetadata['structuredOutput']> { return { status: part.status, raw: part.raw, ...(includeSnapshotStructuredOutput && part.partial !== undefined ? { partial: part.partial } : {}), ...(includeSnapshotStructuredOutput && part.data !== undefined ? { data: part.data } : {}), ...(includeSnapshotStructuredOutput && part.reasoning ? { reasoning: part.reasoning } : {}), ...(part.errorMessage !== undefined && { errorMessage: part.errorMessage }), } } function collectText(parts: ReadonlyArray<MessagePart>): string { // The streamed JSON of a completed structured-output part is the source of // truth for multi-turn coherence — emitting it back as assistant content // lets the LLM see its own prior structured response. Streaming/errored // parts are skipped: they'd ship malformed JSON fragments and confuse the // model. `completeStructuredOutputPart` tries hard to populate `raw` // (caller → existing buffer → `JSON.stringify(data)`), but the stringify // fallback can leave it empty when `data` is unserializable (BigInt, // circular). The `p.raw !== ''` guard below is what enforces "no malformed // round-trip" in that case — without it we'd ship `''` and the model would // see an empty assistant turn. const out: Array<string> = [] for (const p of parts) { if (p.type === 'text') { out.push(p.content) } else if ( p.type === 'structured-output' && p.status === 'complete' && p.raw !== '' ) { out.push(p.raw) } } return out.join('') } function collectUserContent( parts: ReadonlyArray<MessagePart>, ): string | Array<InputContent> { const hasMultimodal = parts.some( (p) => p.type === 'image' || p.type === 'audio' || p.type === 'video' || p.type === 'document', ) if (!hasMultimodal) { return collectText(parts) } const out: Array<InputContent> = [] for (const p of parts) { if (p.type === 'text') { out.push({ type: 'text', text: p.content }) } else if ( p.type === 'image' || p.type === 'audio' || p.type === 'video' || p.type === 'document' ) { out.push(p) } } return out } function thoughtSignatureFromMetadata(metadata: unknown): string | undefined { if ( metadata == null || typeof metadata !== 'object' || Array.isArray(metadata) ) { return undefined } if (!('thoughtSignature' in metadata)) return undefined const value = metadata.thoughtSignature return typeof value === 'string' && value !== '' ? value : undefined } function collectToolCalls( parts: ReadonlyArray<MessagePart>, ): Array<ToolCall> | undefined { const calls: Array<ToolCall> = [] for (const p of parts) { if (p.type === 'tool-call') { const encryptedValue = thoughtSignatureFromMetadata(p.metadata) calls.push({ id: p.id, type: 'function', function: { name: p.name, arguments: p.arguments }, ...(encryptedValue !== undefined ? { encryptedValue } : {}), }) } } return calls.length > 0 ? calls : undefined } function deriveReasoningId(messageId: string, part: MessagePart): string { return `${messageId}-reasoning-${(part as { id?: string }).id ?? hashContent((part as { content: string }).content)}` } function deriveToolMessageId(toolCallId: string): string { return `tool-${toolCallId}` } function uniqueToolWireId(id: string, used: Set<string>): string { return uniqueWireId(id, used) } function uniqueWireId(id: string, used: Set<string>): string { if (!used.has(id)) { used.add(id) return id } let suffix = 2 while (used.has(`${id}-${suffix}`)) suffix++ const unique = `${id}-${suffix}` used.add(unique) return unique } function toolWireId( id: string | undefined, toolCallId: string, assistantIds: ReadonlySet<string>, ): string { const derived = deriveToolMessageId(toolCallId) if (id === undefined || assistantIds.has(id)) return derived return id } function hashContent(s: string): string { // Cheap deterministic id suffix; collisions are tolerable since // reasoning ids only matter for AG-UI server consumers, not for our // own server's dedup logic (which keys on toolCallId, not reasoning id). let h = 0 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 return Math.abs(h).toString(36) }