UNPKG

@tanstack/ai

Version:

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

259 lines (251 loc) • 8.35 kB
import { buildBlockOrder, orderedAssistantBlocks } from './block-order' import { isProviderExecutedToolCall } from './provider-executed' import { tanstackMetadata } from './merge-metadata' import type { BlockOrderEntry, OrderedAssistantBlock } from './block-order' import type { MessageSource, ModelMessage, ToolCall } from '../types' export type ReplayToolIdRule = ( id: string, context: { foreign: boolean source: MessageSource | undefined attempt: number }, ) => string // ponytail: no boundary map. Plan on this array. A later replay must keep the same length. export interface ReplayMessages { messages: Array<ModelMessage> } /** The pi shortHash algorithm, over UTF-16 code units. */ export function hashToolCallId(id: string): string { let h1 = 0xdeadbeef let h2 = 0x41c6ce57 for (let index = 0; index < id.length; index++) { const ch = id.charCodeAt(index) h1 = Math.imul(h1 ^ ch, 2654435761) h2 = Math.imul(h2 ^ ch, 1597334677) } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909) h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909) return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36) } function isForeign( message: ModelMessage, target: MessageSource | undefined, ): boolean { const source = tanstackMetadata(message.metadata)?.source return ( target !== undefined && source !== undefined && (source.provider !== target.provider || source.api !== target.api || source.model !== target.model) ) } /** A string input that does not parse. Some adapters keep an object here. */ function isCutInput(input: unknown): boolean { if (typeof input !== 'string') return false try { JSON.parse(input || '{}') return false } catch { return true } } function isFailed(message: ModelMessage): boolean { const stop = tanstackMetadata(message.metadata)?.stopReason return ( message.role === 'assistant' && (stop === 'error' || stop === 'aborted') ) } /** Build a provider request without changing stored history. An absent target only cleans history. */ export function transformMessagesForReplay( original: ReadonlyArray<ModelMessage>, target?: MessageSource, toolIdRule?: ReplayToolIdRule, ): ReplayMessages { const usedIds = new Set<string>() for (const message of original) { if (!isForeign(message, target) && !isFailed(message)) { for (const call of message.toolCalls ?? []) usedIds.add(call.id) } } const idMap = new Map<string, string>() const failedCallBatch = new Map<string, boolean>() const messages: Array<ModelMessage> = [] let pending: Array<ToolCall> = [] const answered = new Set<string>() // The id of the assistant turn whose calls are pending. A turn split into // segments (`id`, then the same `id` or `${id}-segment-N`) shares its calls. let turnId: string | undefined const closePending = () => { for (const call of pending) { if (!answered.has(call.id) && !isProviderExecutedToolCall(call)) { messages.push({ role: 'tool', toolCallId: call.id, name: call.function.name, content: 'No result provided', error: 'No result provided', }) } } pending = [] answered.clear() turnId = undefined } const mapCall = ( call: ToolCall, foreign: boolean, source: MessageSource | undefined, ): ToolCall => { let id = call.id if (foreign && toolIdRule) { let attempt = 0 do { if (attempt === 1000) throw new Error( `Tool ID rule could not allocate a unique ID for ${call.id}`, ) id = toolIdRule(call.id, { foreign, source, attempt: attempt++ }) } while (usedIds.has(id)) usedIds.add(id) } idMap.set(call.id, id) if (!foreign && id === call.id) return call const metadata = call.metadata if ( metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) ) { const rest = Object.fromEntries( Object.entries(metadata).filter(([key]) => key !== 'thoughtSignature'), ) return { ...call, id, metadata: rest } } return id === call.id ? call : { ...call, id } } for (const message of original) { const nextSegment = message.role === 'assistant' && answered.size === 0 && turnId !== undefined && message.id !== undefined && (message.id === turnId || message.id.startsWith(`${turnId}-segment-`)) if (message.role !== 'tool' && !nextSegment) closePending() if (message.role === 'assistant') { for (const call of message.toolCalls ?? []) failedCallBatch.set(call.id, isFailed(message)) } if (isFailed(message)) continue if (message.role === 'tool') { if ( message.toolCallId !== undefined && failedCallBatch.get(message.toolCallId) ) continue const id = message.toolCallId !== undefined ? idMap.get(message.toolCallId) : undefined const mapped = id !== undefined && id !== message.toolCallId ? { ...message, toolCallId: id } : message if (mapped.toolCallId !== undefined) answered.add(mapped.toolCallId) messages.push(mapped) continue } if (message.role !== 'assistant') { messages.push(message) continue } const foreign = isForeign(message, target) let mapped = message if (foreign) { const defaultBlocks: Array<OrderedAssistantBlock> = [ ...(message.thinking ?? []).map((thinking) => ({ type: 'thinking' as const, thinking, })), ...(typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : []), ...(message.toolCalls ?? []).map((toolCall) => ({ type: 'tool-call' as const, toolCall, })), ] const blocks = orderedAssistantBlocks(message) ?? defaultBlocks const entries: Array<BlockOrderEntry> = [] const calls: Array<ToolCall> = [] let content = '' for (const block of blocks) { if (block.type === 'tool-call') { const call = mapCall( block.toolCall, true, tanstackMetadata(message.metadata)?.source, ) calls.push(call) entries.push({ type: 'tool-call', id: call.id }) } else { const text = block.type === 'text' ? block.text : block.thinking.redacted ? '' : block.thinking.content.trim() === '' ? '' : block.thinking.content if (text) { content += text entries.push({ type: 'text', text }) } } } const { thinking: _thinking, blockOrder: _order, ...rest } = message mapped = { ...rest, content: typeof message.content === 'string' || message.content === null ? content || null : content ? [{ type: 'text', content }, ...message.content] : message.content, ...(message.toolCalls ? { toolCalls: calls } : {}), ...(buildBlockOrder(entries) ? { blockOrder: buildBlockOrder(entries) } : {}), } } else { for (const call of message.toolCalls ?? []) idMap.set(call.id, call.id) } // The output limit can cut a call in the middle of its input. Such a // call did not run, and some providers (Bedrock) reject input that is not // JSON. Other input that is not JSON stays, so such a provider still fails // loudly on it. if ( tanstackMetadata(message.metadata)?.finishReason === 'length' && mapped.toolCalls?.some((call) => isCutInput(call.function.arguments)) ) { mapped = { ...mapped, toolCalls: mapped.toolCalls.map((call) => isCutInput(call.function.arguments) ? { ...call, function: { ...call.function, arguments: '{}' } } : call, ), } } pending = [...pending, ...(mapped.toolCalls ?? [])] turnId ??= message.id messages.push(mapped) } closePending() return { messages } }