@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
324 lines (294 loc) • 9.31 kB
text/typescript
import type {
ContentPart,
MessagePart,
StructuredOutputPart,
TanStackMessageMetadata,
UIMessage,
UIResourcePart,
} from '../types'
import type { MetadataRecord } from './merge-metadata'
import { tanstackMetadata } from './merge-metadata'
type AGUITextInputContent = { type: 'text'; text: string }
type AGUIInputContent =
| AGUITextInputContent
| (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' })
type AGUIToolCallMirror = {
id: string
type: 'function'
function: { name: string; arguments: string }
encryptedValue?: string
}
type AGUIToolMessage = {
role: 'tool'
id: string
toolCallId: string
content: string
error?: string
}
type AGUIReasoningMessage = {
role: 'reasoning'
id: string
content: string
encryptedValue?: string
metadata?: MetadataRecord
}
/** Spec AG-UI message. No `parts`, no `createdAt` Date. */
type WireAnchorMessage = {
id: string
role: UIMessage['role']
name?: string
content?: string | Array<AGUIInputContent>
toolCalls?: Array<AGUIToolCallMirror>
metadata?: MetadataRecord
}
export type WireMessage =
| WireAnchorMessage
| AGUIToolMessage
| AGUIReasoningMessage
/**
* Serialize TanStack `UIMessage`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.
*/
export function uiMessagesToWire(
messages: Array<UIMessage>,
): Array<WireMessage> {
const wire: Array<WireMessage> = []
for (const msg of messages) {
// Defensive: ModelMessage-shaped input has no `parts`; fall back to `content`.
const parts: ReadonlyArray<MessagePart> =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- runtime input may be ModelMessage-shaped (no `parts`); cast forces the optional-chain fallback below to remain in scope
(msg.parts as ReadonlyArray<MessagePart> | undefined) ?? []
if (msg.role === 'system') {
wire.push(
toAnchor(
msg,
{
content:
parts.length > 0
? collectText(parts)
: ((msg as { content?: string }).content ?? ''),
},
parts,
),
)
continue
}
if (msg.role === 'user') {
wire.push(
toAnchor(
msg,
{
content:
parts.length > 0
? collectUserContent(parts)
: ((msg as { content?: string }).content ?? ''),
},
parts,
),
)
continue
}
// assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs
for (const part of parts) {
if (part.type === 'thinking') {
const reasoning: AGUIReasoningMessage = {
role: 'reasoning',
id: deriveReasoningId(msg.id, part),
content: part.content,
}
if (part.signature) {
reasoning.encryptedValue = part.signature
}
wire.push(reasoning)
}
}
const text = collectText(parts)
const toolCalls = collectToolCalls(parts)
wire.push(
toAnchor(
msg,
{
...(text !== '' && { content: text }),
...(toolCalls && { toolCalls }),
},
parts,
),
)
for (const part of parts) {
if (part.type === 'tool-result') {
wire.push({
role: 'tool',
id: deriveToolMessageId(part.toolCallId),
toolCallId: part.toolCallId,
content:
typeof part.content === 'string'
? part.content
: JSON.stringify(part.content),
...(part.error !== undefined && { error: part.error }),
})
}
}
}
return wire
}
function toAnchor(
msg: UIMessage,
extras: {
content?: string | Array<AGUIInputContent>
toolCalls?: Array<AGUIToolCallMirror>
},
parts: ReadonlyArray<MessagePart>,
): WireAnchorMessage {
const metadata = messageMetadata(msg, parts)
const name = (msg as { name?: string }).name
return {
id: msg.id,
role: msg.role,
...(name !== undefined && { name }),
...extras,
...(metadata !== undefined && { metadata }),
}
}
function messageMetadata(
msg: UIMessage,
parts: ReadonlyArray<MessagePart>,
): MetadataRecord | undefined {
const base: MetadataRecord = { ...(msg.metadata ?? {}) }
const tanstack: MetadataRecord = { ...(tanstackMetadata(msg) ?? {}) }
if (msg.createdAt) tanstack.createdAt = msg.createdAt.toISOString()
const leftover = unfinishedStructuredOutput(parts)
if (leftover) tanstack.structuredOutput = leftover
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
return Object.keys(base).length > 0 ? base : undefined
}
function unfinishedStructuredOutput(
parts: ReadonlyArray<MessagePart>,
): TanStackMessageMetadata['structuredOutput'] | undefined {
for (const p of parts) {
if (p.type === 'structured-output' && p.status !== 'complete') {
return structuredOutputLeftover(p)
}
}
return undefined
}
function structuredOutputLeftover(
part: StructuredOutputPart,
): NonNullable<TanStackMessageMetadata['structuredOutput']> {
return {
status: part.status,
raw: part.raw,
...(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<AGUIInputContent> {
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<AGUIInputContent> = []
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<AGUIToolCallMirror> | undefined {
const calls: Array<AGUIToolCallMirror> = []
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 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)
}