Skip to content

Commit eb7a2f4

Browse files
authored
Merge branch 'main' into automated/sync-models
2 parents 9eb538d + 723653c commit eb7a2f4

9 files changed

Lines changed: 485 additions & 77 deletions

File tree

.changeset/ag-ui-core-zod-free.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@tanstack/ai': minor
3+
---
4+
5+
Remove zod from `@tanstack/ai`'s dependency graph entirely.
6+
7+
`@ag-ui/core` is bumped to `0.1.1-canary.beta.0`, which drops zod from its
8+
runtime dependencies and declares it as an optional peer instead. Previously
9+
every `@tanstack/ai` install pulled zod in transitively through it.
10+
11+
`chatParamsFromRequest` / `chatParamsFromRequestBody` were the only zod
12+
consumers in this package — they validated request bodies with AG-UI's
13+
`RunAgentInputSchema`. They now validate the same `RunAgentInput` contract
14+
structurally, so `@tanstack/ai` ships with no schema-validation runtime at all
15+
and neither requires nor suggests zod.
16+
17+
No API change: both helpers keep their signatures, still reject non-conforming
18+
bodies with a migration-pointing `AGUIError` (`chatParamsFromRequest` still
19+
throws a 400 `Response`), and still carry TanStack's canonical `parts` field
20+
through on messages. Validation errors now name the offending field —
21+
`messages[1].content must be a string` instead of a zod issue dump.
22+
23+
zod remains fully supported for defining tools; it is simply no longer
24+
installed on your behalf. If you relied on getting zod transitively without
25+
declaring it, add it explicitly: `npm install zod`.

.github/workflows/pr.yml

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,3 @@ jobs:
4848
run: pnpm run build:all
4949
- name: Publish Previews
5050
run: pnpx pkg-pr-new publish --pnpm './packages/*'
51-
version-preview:
52-
name: Version Preview
53-
runs-on: ubuntu-latest
54-
permissions:
55-
contents: read
56-
pull-requests: write
57-
steps:
58-
- name: Checkout
59-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
60-
with:
61-
persist-credentials: false
62-
- name: Setup Tools
63-
uses: TanStack/config/.github/setup@190f659075ff0845850e330883eb26d7ffd0671f # main
64-
- name: Changeset Preview
65-
uses: TanStack/config/.github/changeset-preview@190f659075ff0845850e330883eb26d7ffd0671f # main

docs/comparison/vercel-ai-sdk.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,10 @@ const logger: ChatMiddleware = {
379379
console.log(`[${ctx.requestId}] Chat started`)
380380
},
381381
onChunk: (ctx, chunk) => {
382-
// Transform, expand, or drop chunks
383-
if ('delta' in chunk && 'messageId' in chunk) {
384-
return { ...chunk, delta: chunk.delta!.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED]') }
382+
// Transform, expand, or drop chunks. `type` is the discriminant, so it
383+
// narrows `chunk` to the matching event and types `delta` as `string`.
384+
if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
385+
return { ...chunk, delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED]') }
385386
}
386387
},
387388
onBeforeToolCall: (ctx, hookCtx) => {

docs/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@
662662
"label": "AG-UI Client Compliance",
663663
"to": "migration/ag-ui-compliance",
664664
"addedAt": "2026-05-16",
665-
"updatedAt": "2026-07-08"
665+
"updatedAt": "2026-07-31"
666666
},
667667
{
668668
"label": "Sampling → modelOptions",

docs/migration/ag-ui-compliance.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,37 @@ Pure AG-UI `RunAgentInput` payloads (no TanStack `parts` field) work end-to-end:
363363

364364
## `@ag-ui/core` bump
365365

366-
`@tanstack/ai` now depends on `@ag-ui/core@^0.0.52`. If your code imports types from `@tanstack/ai` that re-export AG-UI types, you may need minor type adjustments — see the changeset for specifics.
366+
`@tanstack/ai` now depends on `@ag-ui/core@0.1.1-canary.beta.0`. If your code imports types from `@tanstack/ai` that re-export AG-UI types, you may need minor type adjustments — see the changeset for specifics.
367+
368+
### zod is no longer installed for you
369+
370+
`@ag-ui/core` used to list `zod` as a runtime dependency, so every
371+
`@tanstack/ai` install pulled zod in transitively. As of `0.1.x` it declares zod
372+
as an optional peer instead, and `@tanstack/ai` no longer uses zod anywhere —
373+
the package now ships with no schema-validation runtime at all.
374+
375+
`chatParamsFromRequest` / `chatParamsFromRequestBody` were the only zod
376+
consumers: they validated the request body with AG-UI's `RunAgentInputSchema`.
377+
They now validate the same `RunAgentInput` contract structurally. Their
378+
signatures, their thrown types (`AGUIError`, and a 400 `Response` from
379+
`chatParamsFromRequest`), and the `parts` passthrough on messages are all
380+
unchanged. The one visible difference is friendlier failures — the error names
381+
the offending field, e.g.:
382+
383+
```
384+
Request body is not a valid AG-UI RunAgentInput. ... Validation errors: messages[1].content must be a string
385+
```
386+
387+
zod is still fully supported for defining tools; it is just no longer installed
388+
on your behalf. If your project used zod without declaring it — relying on the
389+
transitive copy — add it explicitly:
390+
391+
```bash
392+
npm install zod
393+
```
394+
395+
If you define tools with a different Standard Schema library (ArkType, Valibot),
396+
you can now drop zod entirely.
367397

368398
## Out of scope (existing behavior preserved)
369399

packages/ai/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"tanstack-intent"
9090
],
9191
"dependencies": {
92-
"@ag-ui/core": "^0.0.57",
92+
"@ag-ui/core": "0.1.1-canary.beta.0",
9393
"@standard-schema/spec": "^1.1.0",
9494
"@tanstack/ai-event-client": "workspace:*",
9595
"@tanstack/ai-utils": "workspace:*",

packages/ai/src/utilities/chat-params.ts

Lines changed: 199 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { AGUIError, RunAgentInputSchema } from '@ag-ui/core'
2-
import type { Context as AGUIContext } from '@ag-ui/core'
1+
import { AGUIError } from '@ag-ui/core'
2+
import type {
3+
Context as AGUIContext,
4+
Message as AGUIMessage,
5+
ResumeEntry as AGUIResumeEntry,
6+
Role as AGUIRole,
7+
} from '@ag-ui/core'
38
import type {
49
AnyTool,
510
JSONSchema,
@@ -29,6 +34,162 @@ function isValidParts(value: unknown): value is Array<{ type: string }> {
2934
return true
3035
}
3136

37+
/**
38+
* Keyed by `AGUIRole` so a role added upstream fails to compile here until it
39+
* is handled, rather than silently falling through as an unknown role.
40+
*/
41+
const AGUI_ROLES: Record<AGUIRole, true> = {
42+
developer: true,
43+
system: true,
44+
assistant: true,
45+
user: true,
46+
tool: true,
47+
activity: true,
48+
reasoning: true,
49+
}
50+
51+
function isAGUIRole(value: unknown): value is AGUIRole {
52+
return typeof value === 'string' && value in AGUI_ROLES
53+
}
54+
55+
function isRecord(value: unknown): value is Record<string, unknown> {
56+
return typeof value === 'object' && value !== null && !Array.isArray(value)
57+
}
58+
59+
/**
60+
* Reject the request body, pointing at the migration guide. Mirrors the
61+
* message the previous `RunAgentInputSchema.safeParse` failure produced.
62+
*/
63+
function invalidBody(reason: string): never {
64+
throw new AGUIError(
65+
`Request body is not a valid AG-UI RunAgentInput. ` +
66+
`If you're upgrading from a previous @tanstack/ai-client release, ` +
67+
`see docs/migration/ag-ui-compliance.md. ` +
68+
`Validation errors: ${reason}`,
69+
)
70+
}
71+
72+
function requireString(value: unknown, at: string): string {
73+
if (typeof value !== 'string') invalidBody(`${at} must be a string`)
74+
return value
75+
}
76+
77+
function requireArray(value: unknown, at: string): Array<unknown> {
78+
if (!Array.isArray(value)) invalidBody(`${at} must be an array`)
79+
return value
80+
}
81+
82+
/**
83+
* Assert one AG-UI `Message`, discriminating on `role` exactly as the upstream
84+
* `MessageSchema` discriminated union does. The record view is retained on the
85+
* asserted type so callers can still inspect non-AG-UI extras like `parts`.
86+
*/
87+
function assertAGUIMessage(
88+
value: Record<string, unknown>,
89+
at: string,
90+
): asserts value is Record<string, unknown> & AGUIMessage {
91+
requireString(value.id, `${at}.id`)
92+
93+
const role = value.role
94+
if (!isAGUIRole(role)) {
95+
invalidBody(
96+
`${at}.role must be one of ${Object.keys(AGUI_ROLES).join(' | ')}`,
97+
)
98+
}
99+
100+
switch (role) {
101+
case 'assistant':
102+
// Both optional: a tool-calling turn carries no text content.
103+
if (value.content !== undefined) {
104+
requireString(value.content, `${at}.content`)
105+
}
106+
if (value.toolCalls !== undefined) {
107+
requireArray(value.toolCalls, `${at}.toolCalls`)
108+
}
109+
break
110+
case 'user':
111+
if (typeof value.content !== 'string' && !Array.isArray(value.content)) {
112+
invalidBody(
113+
`${at}.content must be a string or an array of content parts`,
114+
)
115+
}
116+
break
117+
case 'tool':
118+
requireString(value.content, `${at}.content`)
119+
requireString(value.toolCallId, `${at}.toolCallId`)
120+
break
121+
case 'activity':
122+
requireString(value.activityType, `${at}.activityType`)
123+
if (!isRecord(value.content)) {
124+
invalidBody(`${at}.content must be an object`)
125+
}
126+
break
127+
case 'developer':
128+
case 'system':
129+
case 'reasoning':
130+
requireString(value.content, `${at}.content`)
131+
break
132+
}
133+
}
134+
135+
function validateMessage(value: unknown, index: number): AGUIMessage {
136+
const at = `messages[${index}]`
137+
if (!isRecord(value)) invalidBody(`${at} must be an object`)
138+
assertAGUIMessage(value, at)
139+
140+
// `parts` is TanStack's canonical extra, carried through so the UIMessage
141+
// path inside `chat()` can use it. Keep it only when it holds recognized
142+
// part types — the previous schema-based path dropped `parts` during parse
143+
// and re-attached it from the raw body behind this same check.
144+
if ('parts' in value && !isValidParts(value.parts)) {
145+
const withoutParts = { ...value }
146+
Reflect.deleteProperty(withoutParts, 'parts')
147+
return withoutParts
148+
}
149+
return value
150+
}
151+
152+
function validateTool(
153+
value: unknown,
154+
index: number,
155+
): { name: string; description: string; parameters: JSONSchema } {
156+
const at = `tools[${index}]`
157+
if (!isRecord(value)) invalidBody(`${at} must be an object`)
158+
return {
159+
name: requireString(value.name, `${at}.name`),
160+
description: requireString(value.description, `${at}.description`),
161+
// Upstream `ToolSchema` types this as optional `any`; it reaches the
162+
// provider as a raw JSON Schema either way.
163+
parameters: value.parameters as JSONSchema,
164+
}
165+
}
166+
167+
function validateContext(value: unknown, index: number): AGUIContext {
168+
const at = `context[${index}]`
169+
if (!isRecord(value)) invalidBody(`${at} must be an object`)
170+
return {
171+
description: requireString(value.description, `${at}.description`),
172+
value: requireString(value.value, `${at}.value`),
173+
}
174+
}
175+
176+
function validateResumeEntry(value: unknown, index: number): AGUIResumeEntry {
177+
const at = `resume[${index}]`
178+
if (!isRecord(value)) invalidBody(`${at} must be an object`)
179+
const status = value.status
180+
if (status !== 'resolved' && status !== 'cancelled') {
181+
invalidBody(`${at}.status must be "resolved" or "cancelled"`)
182+
}
183+
const entry: AGUIResumeEntry = {
184+
interruptId: requireString(value.interruptId, `${at}.interruptId`),
185+
status,
186+
}
187+
// Omit the key entirely when absent, matching the optional-field shape the
188+
// schema produced.
189+
if (value.payload !== undefined) entry.payload = value.payload
190+
return entry
191+
}
192+
32193
/**
33194
* Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.
34195
*
@@ -37,11 +198,14 @@ function isValidParts(value: unknown): value is Array<{ type: string }> {
37198
* `convertMessagesToModelMessages` handles AG-UI fan-out dedup and
38199
* reasoning/activity/developer-role normalization internally.
39200
*
201+
* Validated structurally against the AG-UI `RunAgentInput` contract without a
202+
* schema library, so this package pulls in no validation runtime of its own.
203+
*
40204
* @throws An error with a migration-pointing message when the body does
41-
* not conform to AG-UI `RunAgentInputSchema`. Surface this as a
205+
* not conform to AG-UI `RunAgentInput`. Surface this as a
42206
* 400 Bad Request to the client.
43207
*/
44-
export function chatParamsFromRequestBody(body: unknown): Promise<{
208+
export async function chatParamsFromRequestBody(body: unknown): Promise<{
45209
messages: Array<UIMessage | ModelMessage>
46210
threadId: string
47211
runId: string
@@ -57,55 +221,41 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{
57221
context: Array<AGUIContext>
58222
aguiContext: Array<AGUIContext>
59223
}> {
60-
const parseResult = RunAgentInputSchema.safeParse(body)
61-
if (!parseResult.success) {
62-
return Promise.reject(
63-
new AGUIError(
64-
`Request body is not a valid AG-UI RunAgentInput. ` +
65-
`If you're upgrading from a previous @tanstack/ai-client release, ` +
66-
`see docs/migration/ag-ui-compliance.md. ` +
67-
`Validation errors: ${parseResult.error.message}`,
68-
),
69-
)
224+
if (!isRecord(body)) invalidBody('body must be a JSON object')
225+
226+
const threadId = requireString(body.threadId, 'threadId')
227+
const runId = requireString(body.runId, 'runId')
228+
const parentRunId =
229+
body.parentRunId === undefined
230+
? undefined
231+
: requireString(body.parentRunId, 'parentRunId')
232+
233+
const messages = requireArray(body.messages, 'messages').map(validateMessage)
234+
const tools = requireArray(body.tools, 'tools').map(validateTool)
235+
const aguiContext = requireArray(body.context, 'context').map(validateContext)
236+
const resume =
237+
body.resume === undefined
238+
? undefined
239+
: requireArray(body.resume, 'resume').map(validateResumeEntry)
240+
241+
if (body.forwardedProps !== undefined && !isRecord(body.forwardedProps)) {
242+
invalidBody('forwardedProps must be an object')
70243
}
71244

72-
const parsed = parseResult.data
73-
const aguiContext = parsed.context
74-
75-
// AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are
76-
// dropped during parse. We re-attach them from the original body so the
77-
// existing UIMessage path inside `chat()` can use them directly.
78-
const rawMessages =
79-
(body as { messages?: Array<Record<string, unknown>> }).messages ?? []
80-
const messages = parsed.messages.map((m, i) => {
81-
const raw = rawMessages[i]
82-
if (
83-
raw &&
84-
typeof raw === 'object' &&
85-
'parts' in raw &&
86-
isValidParts(raw.parts)
87-
) {
88-
return { ...m, parts: raw.parts } as UIMessage | ModelMessage
89-
}
90-
return m as ModelMessage
91-
})
92-
93-
return Promise.resolve({
94-
messages,
95-
threadId: parsed.threadId,
96-
runId: parsed.runId,
97-
parentRunId: parsed.parentRunId,
98-
tools: parsed.tools as Array<{
99-
name: string
100-
description: string
101-
parameters: JSONSchema
102-
}>,
103-
forwardedProps: (parsed.forwardedProps ?? {}) as Record<string, unknown>,
104-
state: parsed.state,
105-
resume: parsed.resume,
245+
return {
246+
// Unknown top-level fields (e.g. a legacy `cursor`) are dropped by
247+
// construction: only the fields below are copied onto the result.
248+
messages: messages as Array<UIMessage | ModelMessage>,
249+
threadId,
250+
runId,
251+
parentRunId,
252+
tools,
253+
forwardedProps: (body.forwardedProps ?? {}) as Record<string, unknown>,
254+
state: body.state,
255+
resume: resume as Array<RunAgentResumeItem> | undefined,
106256
context: aguiContext,
107257
aguiContext,
108-
})
258+
}
109259
}
110260

111261
/**

0 commit comments

Comments
 (0)