UNPKG

@tanstack/ai

Version:

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

183 lines (173 loc) • 6.11 kB
import type { MidConversationChange, MidConversationChanges, ModelMessage, } from '../types' /** * FNV-1a 32-bit over the UTF-16 code units of `content`, as 8 lowercase hex * characters. A short, stable id for one system prompt. */ export function promptHash(content: string): string { let hash = 0x811c9dc5 for (let index = 0; index < content.length; index++) { hash ^= content.charCodeAt(index) hash = Math.imul(hash, 0x01000193) } return (hash >>> 0).toString(16).padStart(8, '0') } export interface MidConversationPlan { changes: MidConversationChanges /** The record to save on the first assistant message of this call, if any. */ record?: MidConversationChange } /** The state after the records: the changes, and every recorded name and hash. */ interface FoldedRecords { changes: MidConversationChanges tools: Array<string> prompts: Array<string> } function foldRecords( messages: ReadonlyArray<ModelMessage>, ): FoldedRecords | undefined { let folded: FoldedRecords | undefined for (let before = 0; before < messages.length; before++) { const message = messages[before] const record = message?.role === 'assistant' ? message.midConversationChange : undefined if (!record) continue const prompts = record.systemPrompts ?? [] if (record.tools) { folded = { changes: { start: { tools: [...record.tools], systemPrompts: prompts.length }, changes: [], }, tools: [...record.tools], prompts: [...prompts], } continue } // A change before any start point is ignored. Compaction can cause it. if (!folded) continue const added = record.toolsAdded ?? [] folded.tools.push(...added) folded.prompts.push(...prompts) folded.changes.changes.push({ before, ...(added.length > 0 && { tools: [...added] }), ...(prompts.length > 0 && { systemPrompts: prompts.length }), }) } return folded } /** * Fold the records in `messages`, compare them with the tools and prompts of * this call, and decide. A change is additive when every recorded tool is * still there, the prompts start with the recorded prompts, and the start * point has a tool or no tool was added. Anything else is a new start point. * Pure. */ export function planMidConversationChanges(input: { messages: ReadonlyArray<ModelMessage> toolNames: ReadonlyArray<string> /** The normalized content of each system prompt, in order. */ systemPrompts: ReadonlyArray<string> }): MidConversationPlan { const tools = [...input.toolNames] const prompts = input.systemPrompts.map((prompt) => promptHash(prompt)) const startPoint: MidConversationPlan = { changes: { start: { tools, systemPrompts: prompts.length }, changes: [] }, record: { tools: [...tools], systemPrompts: prompts }, } const folded = foldRecords(input.messages) if (!folded) return startPoint const toolsAdded = tools.filter((name) => !folded.tools.includes(name)) const promptsAdded = prompts.slice(folded.prompts.length) const isAdditive = folded.tools.every((name) => tools.includes(name)) && folded.prompts.every((hash, index) => prompts[index] === hash) && (folded.changes.start.tools.length > 0 || toolsAdded.length === 0) if (!isAdditive) return startPoint if (toolsAdded.length === 0 && promptsAdded.length === 0) { return { changes: folded.changes } } folded.changes.changes.push({ before: input.messages.length, ...(toolsAdded.length > 0 && { tools: toolsAdded }), ...(promptsAdded.length > 0 && { systemPrompts: promptsAdded.length }), }) return { changes: folded.changes, record: { ...(toolsAdded.length > 0 && { toolsAdded: [...toolsAdded] }), ...(promptsAdded.length > 0 && { systemPrompts: promptsAdded }), }, } } export interface MidConversationRequest<TTool, TPrompt> { startTools: Array<TTool> startSystemPrompts: Array<TPrompt> /** Every added tool, in change order. */ addedTools: Array<TTool> /** The changes by message index (`before`). */ at: Map<number, { tools: Array<TTool>; systemPrompts: Array<TPrompt> }> } /** * For adapters: resolve the names and counts of `changes` against the current * `tools` and `systemPrompts`. Returns `undefined` when a name is missing, a * name is used twice, or the counts do not add up. Then the adapter sends the * request it sends today. */ export function splitMidConversationChanges< TTool extends { name: string }, TPrompt, >(input: { changes: MidConversationChanges tools: ReadonlyArray<TTool> systemPrompts: ReadonlyArray<TPrompt> }): MidConversationRequest<TTool, TPrompt> | undefined { const { changes, tools, systemPrompts } = input const byName = new Map(tools.map((tool) => [tool.name, tool] as const)) const used = new Set<string>() const pick = (names: ReadonlyArray<string>) => { const picked: Array<TTool> = [] for (const name of names) { const tool = byName.get(name) if (!tool || used.has(name)) return undefined used.add(name) picked.push(tool) } return picked } const startTools = pick(changes.start.tools) let offset = changes.start.systemPrompts if (!startTools || offset > systemPrompts.length) return undefined const request: MidConversationRequest<TTool, TPrompt> = { startTools, startSystemPrompts: systemPrompts.slice(0, offset), addedTools: [], at: new Map(), } for (const change of changes.changes) { const added = pick(change.tools ?? []) const count = change.systemPrompts ?? 0 if ( !added || offset + count > systemPrompts.length || request.at.has(change.before) ) { return undefined } request.addedTools.push(...added) request.at.set(change.before, { tools: added, systemPrompts: systemPrompts.slice(offset, offset + count), }) offset += count } // Every current tool and prompt must have its place. if (used.size !== tools.length || offset !== systemPrompts.length) { return undefined } return request }