UNPKG

@tanstack/ai

Version:

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

245 lines (237 loc) • 7.31 kB
import { tanstackMetadata } from './merge-metadata' import type { ToolCallEndEvent } from '../types' import type { AdapterYieldChunk } from './adapter-yield-chunk' type JsonNode = { start: number end: number value: unknown keyToken?: string properties?: Map<string, JsonNode> items?: Array<JsonNode> } function sameJsonValue(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true if (Array.isArray(left) && Array.isArray(right)) return ( left.length === right.length && left.every((value, index) => sameJsonValue(value, right[index])) ) if ( left === null || right === null || typeof left !== 'object' || typeof right !== 'object' || Array.isArray(left) || Array.isArray(right) ) return false const leftKeys = Object.keys(left) const rightKeys = Object.keys(right) return ( leftKeys.length === rightKeys.length && leftKeys.every( (key) => Object.prototype.hasOwnProperty.call(right, key) && sameJsonValue(Reflect.get(left, key), Reflect.get(right, key)), ) ) } function scanJson(raw: string, parsed: unknown): JsonNode { let cursor = 0 const whitespace = () => { while ( raw[cursor] === ' ' || raw[cursor] === '\t' || raw[cursor] === '\r' || raw[cursor] === '\n' ) cursor++ } const stringToken = () => { const start = cursor++ while (cursor < raw.length) { if (raw[cursor] === '\\') cursor += 2 else if (raw[cursor++] === '"') break } return raw.slice(start, cursor) } const value = (parsedValue: unknown): JsonNode => { whitespace() const start = cursor if (raw[cursor] === '{') { cursor++ whitespace() const properties = new Map<string, JsonNode>() while (raw[cursor] !== '}') { const keyToken = stringToken() const key: unknown = JSON.parse(keyToken) whitespace() cursor++ const child = parsedValue !== null && typeof parsedValue === 'object' && typeof key === 'string' ? Reflect.get(parsedValue, key) : undefined const childNode = value(child) if (typeof key === 'string') properties.set(key, { ...childNode, keyToken }) whitespace() if (raw[cursor] !== ',') break cursor++ whitespace() } cursor++ return { start, end: cursor, value: parsedValue, properties } } if (raw[cursor] === '[') { cursor++ whitespace() const items: Array<JsonNode> = [] while (raw[cursor] !== ']') { items.push( value( Array.isArray(parsedValue) ? parsedValue[items.length] : undefined, ), ) whitespace() if (raw[cursor] !== ',') break cursor++ } cursor++ return { start, end: cursor, value: parsedValue, items } } if (raw[cursor] === '"') stringToken() else while (cursor < raw.length && !/[\s,}\]]/.test(raw[cursor] ?? '')) cursor++ return { start, end: cursor, value: parsedValue } } return value(parsed) } function serializeValue( value: unknown, raw: string, node: JsonNode | undefined, seen: Set<object>, ): string | undefined { if (node && sameJsonValue(node.value, value)) return raw.slice(node.start, node.end) if (typeof value === 'number') { if (Object.is(value, -0)) return '-0' if (value === Infinity) return '1e999' if (value === -Infinity) return '-1e999' return JSON.stringify(value) } if (value === null || typeof value !== 'object') return JSON.stringify(value) if (seen.has(value)) throw new TypeError('Cannot serialize circular tool input') seen.add(value) try { if (Array.isArray(value)) return ( '[' + Array.from( value, (item, index) => serializeValue(item, raw, node?.items?.[index], seen) ?? 'null', ).join(',') + ']' ) const properties: Array<string> = [] for (const key of Object.keys(value)) { const serialized = serializeValue( Reflect.get(value, key), raw, node?.properties?.get(key), seen, ) if (serialized !== undefined) properties.push( (node?.properties?.get(key)?.keyToken ?? JSON.stringify(key)) + ':' + serialized, ) } return '{' + properties.join(',') + '}' } finally { seen.delete(value) } } /** * Keep `raw` when it is the same JSON value as `input`. When they differ, * write `input` again and keep the raw tokens of the values that did not * change. Empty or missing raw arguments use the input. */ export function reconcileToolCallArguments( raw: string | undefined, input: unknown, ): string { if (input === undefined) return raw ?? '' if (raw === undefined) return serializeValue(input, '', undefined, new Set()) ?? '' let parsed: unknown try { parsed = JSON.parse(raw) } catch { // Empty raw arguments are no arguments: the input is the value to keep. if (raw.trim() === '') return serializeValue(input, '', undefined, new Set()) ?? raw return raw } if (sameJsonValue(parsed, input)) return raw return serializeValue(input, raw, scanJson(raw, parsed), new Set()) ?? raw } /** * The input of a tool call from its raw arguments: `{}` for an empty string, * `undefined` when the arguments are not complete JSON (for example cut off) * or are `null`. */ export function parseToolArguments(args: string): unknown { if (args.trim() === '') return {} try { const parsed: unknown = JSON.parse(args) return parsed ?? undefined } catch { return undefined } } /** * The final arguments and input of a tool call at TOOL_CALL_END. `streamed` * is the text of its TOOL_CALL_ARGS deltas. The END's raw `args` (the * provider's own string) win over it, and stay as they are when they are the * same JSON value as the END's `input`. Malformed arguments also stay, so the * tool reports the error and the model can repair them. `input` is always * the parse of `args`. */ export function finishToolCallArguments( streamed: string, end: ToolCallEndEvent, ): { args: string; input: unknown } { const metadata = tanstackMetadata(end) // Wire data: only a string is raw arguments. const endArgs: unknown = (end as AdapterYieldChunk).args ?? metadata?.args const rawArgs = typeof endArgs === 'string' ? endArgs : undefined const endInput = end.input ?? metadata?.input // Only an object or array is tool input. Anything else (`null`, a // function) keeps the raw or streamed arguments. const input = endInput !== null && typeof endInput === 'object' ? endInput : undefined let raw: string | undefined = rawArgs ?? streamed // Streamed text that is not JSON gives way to the input. if (rawArgs === undefined && input !== undefined) { try { JSON.parse(raw) } catch { raw = undefined } } let args: string try { args = reconcileToolCallArguments(raw, input) } catch { // An input with no JSON form (a cycle, a bigint): keep the raw arguments. args = raw ?? streamed } return { args, input: parseToolArguments(args) } }