@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
405 lines (379 loc) • 13.6 kB
text/typescript
import { AGUIError } from '@ag-ui/core'
import type {
Context as AGUIContext,
Message as AGUIMessage,
ResumeEntry as AGUIResumeEntry,
Role as AGUIRole,
} from '@ag-ui/core'
import type {
AnyTool,
JSONSchema,
ModelMessage,
RunAgentResumeItem,
UIMessage,
} from '../types'
const KNOWN_PART_TYPES = new Set([
'text',
'image',
'audio',
'video',
'document',
'tool-call',
'tool-result',
'thinking',
])
function isValidParts(value: unknown): value is Array<{ type: string }> {
if (!Array.isArray(value)) return false
for (const p of value) {
if (!p || typeof p !== 'object') return false
const type = (p as { type?: unknown }).type
if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false
}
return true
}
/**
* Keyed by `AGUIRole` so a role added upstream fails to compile here until it
* is handled, rather than silently falling through as an unknown role.
*/
const AGUI_ROLES: Record<AGUIRole, true> = {
developer: true,
system: true,
assistant: true,
user: true,
tool: true,
activity: true,
reasoning: true,
}
function isAGUIRole(value: unknown): value is AGUIRole {
return typeof value === 'string' && value in AGUI_ROLES
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Reject the request body, pointing at the migration guide. Mirrors the
* message the previous `RunAgentInputSchema.safeParse` failure produced.
*/
function invalidBody(reason: string): never {
throw new AGUIError(
`Request body is not a valid AG-UI RunAgentInput. ` +
`If you're upgrading from a previous @tanstack/ai-client release, ` +
`see docs/migration/ag-ui-compliance.md. ` +
`Validation errors: ${reason}`,
)
}
function requireString(value: unknown, at: string): string {
if (typeof value !== 'string') invalidBody(`${at} must be a string`)
return value
}
function requireArray(value: unknown, at: string): Array<unknown> {
if (!Array.isArray(value)) invalidBody(`${at} must be an array`)
return value
}
/**
* Assert one AG-UI `Message`, discriminating on `role` exactly as the upstream
* `MessageSchema` discriminated union does. The record view is retained on the
* asserted type so callers can still inspect non-AG-UI extras like `parts`.
*/
function assertAGUIMessage(
value: Record<string, unknown>,
at: string,
): asserts value is Record<string, unknown> & AGUIMessage {
requireString(value.id, `${at}.id`)
const role = value.role
if (!isAGUIRole(role)) {
invalidBody(
`${at}.role must be one of ${Object.keys(AGUI_ROLES).join(' | ')}`,
)
}
switch (role) {
case 'assistant':
// Both optional: a tool-calling turn carries no text content.
if (value.content !== undefined) {
requireString(value.content, `${at}.content`)
}
if (value.toolCalls !== undefined) {
requireArray(value.toolCalls, `${at}.toolCalls`)
}
break
case 'user':
if (typeof value.content !== 'string' && !Array.isArray(value.content)) {
invalidBody(
`${at}.content must be a string or an array of content parts`,
)
}
break
case 'tool':
requireString(value.content, `${at}.content`)
requireString(value.toolCallId, `${at}.toolCallId`)
break
case 'activity':
requireString(value.activityType, `${at}.activityType`)
if (!isRecord(value.content)) {
invalidBody(`${at}.content must be an object`)
}
break
case 'developer':
case 'system':
case 'reasoning':
requireString(value.content, `${at}.content`)
break
}
}
function validateMessage(value: unknown, index: number): AGUIMessage {
const at = `messages[${index}]`
if (!isRecord(value)) invalidBody(`${at} must be an object`)
assertAGUIMessage(value, at)
// `parts` is TanStack's canonical extra, carried through so the UIMessage
// path inside `chat()` can use it. Keep it only when it holds recognized
// part types — the previous schema-based path dropped `parts` during parse
// and re-attached it from the raw body behind this same check.
if ('parts' in value && !isValidParts(value.parts)) {
const withoutParts = { ...value }
Reflect.deleteProperty(withoutParts, 'parts')
return withoutParts
}
return value
}
function validateTool(
value: unknown,
index: number,
): { name: string; description: string; parameters: JSONSchema } {
const at = `tools[${index}]`
if (!isRecord(value)) invalidBody(`${at} must be an object`)
return {
name: requireString(value.name, `${at}.name`),
description: requireString(value.description, `${at}.description`),
// Upstream `ToolSchema` types this as optional `any`; it reaches the
// provider as a raw JSON Schema either way.
parameters: value.parameters as JSONSchema,
}
}
function validateContext(value: unknown, index: number): AGUIContext {
const at = `context[${index}]`
if (!isRecord(value)) invalidBody(`${at} must be an object`)
return {
description: requireString(value.description, `${at}.description`),
value: requireString(value.value, `${at}.value`),
}
}
function validateResumeEntry(value: unknown, index: number): AGUIResumeEntry {
const at = `resume[${index}]`
if (!isRecord(value)) invalidBody(`${at} must be an object`)
const status = value.status
if (status !== 'resolved' && status !== 'cancelled') {
invalidBody(`${at}.status must be "resolved" or "cancelled"`)
}
const entry: AGUIResumeEntry = {
interruptId: requireString(value.interruptId, `${at}.interruptId`),
status,
}
// Omit the key entirely when absent, matching the optional-field shape the
// schema produced.
if (value.payload !== undefined) entry.payload = value.payload
return entry
}
/**
* Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.
*
* Returns a spread-friendly object whose `messages` field is suitable for
* passing directly to `chat({ messages })`. The existing
* `convertMessagesToModelMessages` handles AG-UI fan-out dedup and
* reasoning/activity/developer-role normalization internally.
*
* Validated structurally against the AG-UI `RunAgentInput` contract without a
* schema library, so this package pulls in no validation runtime of its own.
*
* @throws An error with a migration-pointing message when the body does
* not conform to AG-UI `RunAgentInput`. Surface this as a
* 400 Bad Request to the client.
*/
export async function chatParamsFromRequestBody(body: unknown): Promise<{
messages: Array<UIMessage | ModelMessage>
threadId: string
runId: string
parentRunId?: string
tools: Array<{ name: string; description: string; parameters: JSONSchema }>
forwardedProps: Record<string, unknown>
state: unknown
resume?: Array<RunAgentResumeItem>
/**
* @deprecated Use `aguiContext` instead. This alias will be removed in a
* future release.
*/
context: Array<AGUIContext>
aguiContext: Array<AGUIContext>
}> {
if (!isRecord(body)) invalidBody('body must be a JSON object')
const threadId = requireString(body.threadId, 'threadId')
const runId = requireString(body.runId, 'runId')
const parentRunId =
body.parentRunId === undefined
? undefined
: requireString(body.parentRunId, 'parentRunId')
const messages = requireArray(body.messages, 'messages').map(validateMessage)
const tools = requireArray(body.tools, 'tools').map(validateTool)
const aguiContext = requireArray(body.context, 'context').map(validateContext)
const resume =
body.resume === undefined
? undefined
: requireArray(body.resume, 'resume').map(validateResumeEntry)
if (body.forwardedProps !== undefined && !isRecord(body.forwardedProps)) {
invalidBody('forwardedProps must be an object')
}
return {
// Unknown top-level fields (e.g. a legacy `cursor`) are dropped by
// construction: only the fields below are copied onto the result.
messages: messages as Array<UIMessage | ModelMessage>,
threadId,
runId,
parentRunId,
tools,
forwardedProps: (body.forwardedProps ?? {}) as Record<string, unknown>,
state: body.state,
resume: resume as Array<RunAgentResumeItem> | undefined,
context: aguiContext,
aguiContext,
}
}
/**
* Read an HTTP `Request`, parse its JSON body, and validate it as an
* AG-UI `RunAgentInput` — collapsing the standard `req.json()` +
* `chatParamsFromRequestBody(...)` pair into a single call.
*
* On a malformed body or invalid AG-UI shape, this **throws a
* `Response`** with status 400 and a migration-pointing message in the
* body. Frameworks that natively handle thrown `Response` objects
* (TanStack Start, SolidStart, Remix, React Router 7) will return the
* 400 to the client automatically, so the handler reduces to:
*
* ```ts
* export async function POST(req: Request) {
* const params = await chatParamsFromRequest(req)
* // ...use params
* }
* ```
*
* In frameworks that do not auto-handle thrown `Response` objects
* (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call
* with try/catch and return the caught Response yourself, or use
* `chatParamsFromRequestBody` directly with your own JSON-parsing.
*
* @throws {Response} 400 on malformed JSON or invalid AG-UI shape.
*/
export async function chatParamsFromRequest(
req: Request,
): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>> {
let body: unknown
try {
body = await req.json()
} catch (cause) {
// Preserve the underlying error on the thrown Response for
// server-side observability without leaking it to the client.
const res = new Response(
'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',
{ status: 400 },
)
;(res as { cause?: unknown }).cause = cause
throw res
}
try {
return await chatParamsFromRequestBody(body)
} catch (cause) {
// Generic public message — avoid echoing Zod paths (which can contain
// user payload fragments) or internal validator strings to the client.
// The original AGUIError is attached as `cause` so server logs can
// surface it without exposing it to remote callers.
const res = new Response(
'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',
{ status: 400 },
)
;(res as { cause?: unknown }).cause = cause
throw res
}
}
/**
* Client-declared tool stub (no execute). `name` is `string`, so arrays that
* include these stubs intentionally widen `TypedStreamChunk` tool-name
* discrimination — pass server tools alone when you need a closed name union.
*/
export type ClientToolDeclaration = {
name: string
description: string
inputSchema: JSONSchema
}
export type MergedAgentTools<TServerTools extends ReadonlyArray<AnyTool>> =
ReadonlyArray<TServerTools[number] | ClientToolDeclaration>
/**
* Merge a server-side tool array with the AG-UI client-declared tools
* received in the request body.
*
* Rules:
* - Server tools win on name collision. The client's declaration is
* ignored if the server already has a tool with that name. The client's
* UI-side handler still fires when the streamed tool-result event comes
* through (see `chat-client.ts` `onToolCall`), giving the
* "after server execution the client also handles" semantic for free.
* - Client-only tools (name not in `serverTools`) become no-execute
* entries: the runtime's existing `ClientToolRequest` path handles
* them — server emits a tool-call request, client executes via its
* registered handler, client posts back the result.
*
* Typing:
* - Empty `clientTools` preserves the server tuple (closed name union).
* - Non-empty `clientTools` returns a widened array that honestly includes
* client stubs, so `TypedStreamChunk` does not claim a closed server-only
* name union.
*
* @param serverTools - The server's tool array (e.g. from
* `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.
* @param clientTools - The `tools` array received from
* `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.
* @returns A merged array suitable for `chat({ tools })`.
*/
export function mergeAgentTools<
const TServerTools extends ReadonlyArray<AnyTool>,
>(serverTools: TServerTools, clientTools: readonly []): TServerTools
export function mergeAgentTools<
const TServerTools extends ReadonlyArray<AnyTool>,
>(
serverTools: TServerTools,
clientTools: ReadonlyArray<{
name: string
description: string
parameters: JSONSchema
}>,
): MergedAgentTools<TServerTools>
export function mergeAgentTools<
const TServerTools extends ReadonlyArray<AnyTool>,
>(
serverTools: TServerTools,
clientTools: ReadonlyArray<{
name: string
description: string
parameters: JSONSchema
}>,
): TServerTools | MergedAgentTools<TServerTools> {
if (clientTools.length === 0) {
return serverTools
}
const seen = new Set(serverTools.map((t) => t.name))
const merged: Array<TServerTools[number] | ClientToolDeclaration> = [
...serverTools,
]
for (const ct of clientTools) {
if (seen.has(ct.name)) {
// Server wins on name collision.
continue
}
seen.add(ct.name)
merged.push({
name: ct.name,
description: ct.description,
inputSchema: ct.parameters,
// No `execute` — runtime treats this as a client-side tool and
// emits ClientToolRequest events.
})
}
return merged
}