@tanstack/ai 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/activities/chat/adapter.d.ts +14 -0
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.d.ts +27 -8
- package/dist/esm/activities/chat/index.js +245 -14
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +26 -2
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.js +1 -1
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +12 -1
- package/dist/esm/activities/chat/tools/schema-converter.js +5 -0
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/error-payload.d.ts +0 -8
- package/dist/esm/activities/error-payload.js +20 -2
- package/dist/esm/activities/error-payload.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.d.ts +2 -2
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +2 -2
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/index.d.ts +1 -0
- package/dist/esm/activities/index.js +2 -0
- package/dist/esm/activities/index.js.map +1 -1
- package/dist/esm/activities/stream-generation-result.js +0 -2
- package/dist/esm/activities/stream-generation-result.js.map +1 -1
- package/dist/esm/activities/summarize/adapter.d.ts +4 -4
- package/dist/esm/activities/summarize/adapter.js.map +1 -1
- package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +45 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js +148 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -0
- package/dist/esm/activities/summarize/index.d.ts +1 -0
- package/dist/esm/activities/summarize/index.js +4 -2
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +123 -11
- package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
- package/dist/esm/utilities/ag-ui-wire.js +96 -0
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
- package/dist/esm/utilities/chat-params.d.ts +80 -0
- package/dist/esm/utilities/chat-params.js +96 -0
- package/dist/esm/utilities/chat-params.js.map +1 -0
- package/package.json +3 -3
- package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
- package/skills/ai-core/structured-outputs/SKILL.md +92 -1
- package/src/activities/chat/adapter.ts +17 -0
- package/src/activities/chat/index.ts +401 -35
- package/src/activities/chat/messages.ts +44 -4
- package/src/activities/chat/middleware/compose.ts +1 -1
- package/src/activities/chat/middleware/types.ts +12 -1
- package/src/activities/chat/tools/schema-converter.ts +14 -0
- package/src/activities/error-payload.ts +31 -2
- package/src/activities/generateImage/adapter.ts +8 -2
- package/src/activities/generateVideo/adapter.ts +8 -2
- package/src/activities/index.ts +5 -0
- package/src/activities/stream-generation-result.ts +4 -6
- package/src/activities/summarize/adapter.ts +8 -4
- package/src/activities/summarize/chat-stream-summarize.ts +238 -0
- package/src/activities/summarize/index.ts +12 -9
- package/src/index.ts +11 -0
- package/src/types.ts +146 -11
- package/src/utilities/ag-ui-wire.ts +182 -0
- package/src/utilities/chat-params.ts +199 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function uiMessagesToWire(messages) {
|
|
2
|
+
const wire = [];
|
|
3
|
+
for (const msg of messages) {
|
|
4
|
+
const parts = msg.parts ?? [];
|
|
5
|
+
if (msg.role === "system") {
|
|
6
|
+
wire.push({
|
|
7
|
+
...msg,
|
|
8
|
+
content: parts.length > 0 ? collectText(parts) : msg.content ?? ""
|
|
9
|
+
});
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (msg.role === "user") {
|
|
13
|
+
wire.push({
|
|
14
|
+
...msg,
|
|
15
|
+
content: parts.length > 0 ? collectUserContent(parts) : msg.content ?? ""
|
|
16
|
+
});
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (part.type === "thinking") {
|
|
21
|
+
wire.push({
|
|
22
|
+
role: "reasoning",
|
|
23
|
+
id: deriveReasoningId(msg.id, part),
|
|
24
|
+
content: part.content
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const text = collectText(parts);
|
|
29
|
+
const toolCalls = collectToolCalls(parts);
|
|
30
|
+
wire.push({
|
|
31
|
+
...msg,
|
|
32
|
+
...text !== "" && { content: text },
|
|
33
|
+
...toolCalls && { toolCalls }
|
|
34
|
+
});
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
if (part.type === "tool-result") {
|
|
37
|
+
wire.push({
|
|
38
|
+
role: "tool",
|
|
39
|
+
id: deriveToolMessageId(part.toolCallId),
|
|
40
|
+
toolCallId: part.toolCallId,
|
|
41
|
+
content: part.content,
|
|
42
|
+
...part.error !== void 0 && { error: part.error }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return wire;
|
|
48
|
+
}
|
|
49
|
+
function collectText(parts) {
|
|
50
|
+
return parts.filter((p) => p.type === "text").map((p) => p.content).join("");
|
|
51
|
+
}
|
|
52
|
+
function collectUserContent(parts) {
|
|
53
|
+
const hasMultimodal = parts.some(
|
|
54
|
+
(p) => p.type === "image" || p.type === "audio" || p.type === "video" || p.type === "document"
|
|
55
|
+
);
|
|
56
|
+
if (!hasMultimodal) {
|
|
57
|
+
return collectText(parts);
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const p of parts) {
|
|
61
|
+
if (p.type === "text") {
|
|
62
|
+
out.push({ type: "text", text: p.content });
|
|
63
|
+
} else if (p.type === "image" || p.type === "audio" || p.type === "video" || p.type === "document") {
|
|
64
|
+
out.push(p);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function collectToolCalls(parts) {
|
|
70
|
+
const calls = [];
|
|
71
|
+
for (const p of parts) {
|
|
72
|
+
if (p.type === "tool-call") {
|
|
73
|
+
calls.push({
|
|
74
|
+
id: p.id,
|
|
75
|
+
type: "function",
|
|
76
|
+
function: { name: p.name, arguments: p.arguments }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return calls.length > 0 ? calls : void 0;
|
|
81
|
+
}
|
|
82
|
+
function deriveReasoningId(messageId, part) {
|
|
83
|
+
return `${messageId}-reasoning-${part.id ?? hashContent(part.content)}`;
|
|
84
|
+
}
|
|
85
|
+
function deriveToolMessageId(toolCallId) {
|
|
86
|
+
return `tool-${toolCallId}`;
|
|
87
|
+
}
|
|
88
|
+
function hashContent(s) {
|
|
89
|
+
let h = 0;
|
|
90
|
+
for (let i = 0; i < s.length; i++) h = h * 31 + s.charCodeAt(i) | 0;
|
|
91
|
+
return Math.abs(h).toString(36);
|
|
92
|
+
}
|
|
93
|
+
export {
|
|
94
|
+
uiMessagesToWire
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=ag-ui-wire.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ag-ui-wire.js","sources":["../../../src/utilities/ag-ui-wire.ts"],"sourcesContent":["import type { ContentPart, MessagePart, TextPart, UIMessage } from '../types'\n\ntype AGUITextInputContent = { type: 'text'; text: string }\ntype AGUIInputContent =\n | AGUITextInputContent\n | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' })\n\ntype AGUIToolCallMirror = {\n id: string\n type: 'function'\n function: { name: string; arguments: string }\n}\n\ntype AGUIToolMessage = {\n role: 'tool'\n id: string\n toolCallId: string\n content: string\n error?: string\n}\n\ntype AGUIReasoningMessage = {\n role: 'reasoning'\n id: string\n content: string\n}\n\ntype WireAnchorMessage = UIMessage & {\n content?: string | Array<AGUIInputContent>\n toolCalls?: Array<AGUIToolCallMirror>\n}\n\nexport type WireMessage =\n | WireAnchorMessage\n | AGUIToolMessage\n | AGUIReasoningMessage\n\n/**\n * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages`\n * wire shape. Each anchor (system/user/assistant) carries the canonical\n * `parts` array verbatim plus AG-UI mirror fields (`content`, `toolCalls`)\n * so AG-UI Zod parsing succeeds. Tool results and thinking parts on\n * assistant messages are additionally emitted as fan-out\n * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict\n * AG-UI server consumers.\n */\nexport function uiMessagesToWire(\n messages: Array<UIMessage>,\n): Array<WireMessage> {\n const wire: Array<WireMessage> = []\n\n for (const msg of messages) {\n // Defensive: if parts is missing (ModelMessage-shaped input), pass through as-is.\n // UIMessage always has parts; ModelMessage uses content directly.\n const parts: ReadonlyArray<MessagePart> =\n (msg.parts as ReadonlyArray<MessagePart> | undefined) ?? []\n\n if (msg.role === 'system') {\n wire.push({\n ...msg,\n content:\n parts.length > 0\n ? collectText(parts)\n : ((msg as unknown as { content?: string }).content ?? ''),\n })\n continue\n }\n\n if (msg.role === 'user') {\n wire.push({\n ...msg,\n content:\n parts.length > 0\n ? collectUserContent(parts)\n : ((msg as unknown as { content?: string }).content ?? ''),\n })\n continue\n }\n\n // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs\n for (const part of parts) {\n if (part.type === 'thinking') {\n wire.push({\n role: 'reasoning',\n id: deriveReasoningId(msg.id, part),\n content: part.content,\n })\n }\n }\n\n const text = collectText(parts)\n const toolCalls = collectToolCalls(parts)\n wire.push({\n ...msg,\n ...(text !== '' && { content: text }),\n ...(toolCalls && { toolCalls }),\n })\n\n for (const part of parts) {\n if (part.type === 'tool-result') {\n wire.push({\n role: 'tool',\n id: deriveToolMessageId(part.toolCallId),\n toolCallId: part.toolCallId,\n content: part.content,\n ...(part.error !== undefined && { error: part.error }),\n })\n }\n }\n }\n\n return wire\n}\n\nfunction collectText(parts: ReadonlyArray<MessagePart>): string {\n return parts\n .filter((p): p is TextPart => p.type === 'text')\n .map((p) => p.content)\n .join('')\n}\n\nfunction collectUserContent(\n parts: ReadonlyArray<MessagePart>,\n): string | Array<AGUIInputContent> {\n const hasMultimodal = parts.some(\n (p) =>\n p.type === 'image' ||\n p.type === 'audio' ||\n p.type === 'video' ||\n p.type === 'document',\n )\n if (!hasMultimodal) {\n return collectText(parts)\n }\n const out: Array<AGUIInputContent> = []\n for (const p of parts) {\n if (p.type === 'text') {\n out.push({ type: 'text', text: p.content })\n } else if (\n p.type === 'image' ||\n p.type === 'audio' ||\n p.type === 'video' ||\n p.type === 'document'\n ) {\n out.push(p as AGUIInputContent)\n }\n }\n return out\n}\n\nfunction collectToolCalls(\n parts: ReadonlyArray<MessagePart>,\n): Array<AGUIToolCallMirror> | undefined {\n const calls: Array<AGUIToolCallMirror> = []\n for (const p of parts) {\n if (p.type === 'tool-call') {\n calls.push({\n id: p.id,\n type: 'function',\n function: { name: p.name, arguments: p.arguments },\n })\n }\n }\n return calls.length > 0 ? calls : undefined\n}\n\nfunction deriveReasoningId(messageId: string, part: MessagePart): string {\n return `${messageId}-reasoning-${(part as { id?: string }).id ?? hashContent((part as { content: string }).content)}`\n}\n\nfunction deriveToolMessageId(toolCallId: string): string {\n return `tool-${toolCallId}`\n}\n\nfunction hashContent(s: string): string {\n // Cheap deterministic id suffix; collisions are tolerable since\n // reasoning ids only matter for AG-UI server consumers, not for our\n // own server's dedup logic (which keys on toolCallId, not reasoning id).\n let h = 0\n for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0\n return Math.abs(h).toString(36)\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Context as AGUIContext } from '@ag-ui/core';
|
|
2
|
+
import { JSONSchema, ModelMessage, Tool, UIMessage } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.
|
|
5
|
+
*
|
|
6
|
+
* Returns a spread-friendly object whose `messages` field is suitable for
|
|
7
|
+
* passing directly to `chat({ messages })`. The existing
|
|
8
|
+
* `convertMessagesToModelMessages` handles AG-UI fan-out dedup and
|
|
9
|
+
* reasoning/activity/developer-role normalization internally.
|
|
10
|
+
*
|
|
11
|
+
* @throws An error with a migration-pointing message when the body does
|
|
12
|
+
* not conform to AG-UI 0.0.52 `RunAgentInputSchema`. Surface this as a
|
|
13
|
+
* 400 Bad Request to the client.
|
|
14
|
+
*/
|
|
15
|
+
export declare function chatParamsFromRequestBody(body: unknown): Promise<{
|
|
16
|
+
messages: Array<UIMessage | ModelMessage>;
|
|
17
|
+
threadId: string;
|
|
18
|
+
runId: string;
|
|
19
|
+
parentRunId?: string;
|
|
20
|
+
tools: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
parameters: JSONSchema;
|
|
24
|
+
}>;
|
|
25
|
+
forwardedProps: Record<string, unknown>;
|
|
26
|
+
state: unknown;
|
|
27
|
+
context: Array<AGUIContext>;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Read an HTTP `Request`, parse its JSON body, and validate it as an
|
|
31
|
+
* AG-UI `RunAgentInput` — collapsing the standard `req.json()` +
|
|
32
|
+
* `chatParamsFromRequestBody(...)` pair into a single call.
|
|
33
|
+
*
|
|
34
|
+
* On a malformed body or invalid AG-UI shape, this **throws a
|
|
35
|
+
* `Response`** with status 400 and a migration-pointing message in the
|
|
36
|
+
* body. Frameworks that natively handle thrown `Response` objects
|
|
37
|
+
* (TanStack Start, SolidStart, Remix, React Router 7) will return the
|
|
38
|
+
* 400 to the client automatically, so the handler reduces to:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* export async function POST(req: Request) {
|
|
42
|
+
* const params = await chatParamsFromRequest(req)
|
|
43
|
+
* // ...use params
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* In frameworks that do not auto-handle thrown `Response` objects
|
|
48
|
+
* (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call
|
|
49
|
+
* with try/catch and return the caught Response yourself, or use
|
|
50
|
+
* `chatParamsFromRequestBody` directly with your own JSON-parsing.
|
|
51
|
+
*
|
|
52
|
+
* @throws {Response} 400 on malformed JSON or invalid AG-UI shape.
|
|
53
|
+
*/
|
|
54
|
+
export declare function chatParamsFromRequest(req: Request): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>>;
|
|
55
|
+
/**
|
|
56
|
+
* Merge a server-side tool array with the AG-UI client-declared tools
|
|
57
|
+
* received in the request body.
|
|
58
|
+
*
|
|
59
|
+
* Rules:
|
|
60
|
+
* - Server tools win on name collision. The client's declaration is
|
|
61
|
+
* ignored if the server already has a tool with that name. The client's
|
|
62
|
+
* UI-side handler still fires when the streamed tool-result event comes
|
|
63
|
+
* through (see `chat-client.ts` `onToolCall`), giving the
|
|
64
|
+
* "after server execution the client also handles" semantic for free.
|
|
65
|
+
* - Client-only tools (name not in `serverTools`) become no-execute
|
|
66
|
+
* entries: the runtime's existing `ClientToolRequest` path handles
|
|
67
|
+
* them — server emits a tool-call request, client executes via its
|
|
68
|
+
* registered handler, client posts back the result.
|
|
69
|
+
*
|
|
70
|
+
* @param serverTools - The server's tool array (e.g. from
|
|
71
|
+
* `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.
|
|
72
|
+
* @param clientTools - The `tools` array received from
|
|
73
|
+
* `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.
|
|
74
|
+
* @returns A merged array suitable for `chat({ tools })`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function mergeAgentTools(serverTools: ReadonlyArray<Tool>, clientTools: ReadonlyArray<{
|
|
77
|
+
name: string;
|
|
78
|
+
description: string;
|
|
79
|
+
parameters: JSONSchema;
|
|
80
|
+
}>): Array<Tool>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { RunAgentInputSchema, AGUIError } from "@ag-ui/core";
|
|
2
|
+
const KNOWN_PART_TYPES = /* @__PURE__ */ new Set([
|
|
3
|
+
"text",
|
|
4
|
+
"image",
|
|
5
|
+
"audio",
|
|
6
|
+
"video",
|
|
7
|
+
"document",
|
|
8
|
+
"tool-call",
|
|
9
|
+
"tool-result",
|
|
10
|
+
"thinking"
|
|
11
|
+
]);
|
|
12
|
+
function isValidParts(value) {
|
|
13
|
+
if (!Array.isArray(value)) return false;
|
|
14
|
+
for (const p of value) {
|
|
15
|
+
if (!p || typeof p !== "object") return false;
|
|
16
|
+
const type = p.type;
|
|
17
|
+
if (typeof type !== "string" || !KNOWN_PART_TYPES.has(type)) return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
function chatParamsFromRequestBody(body) {
|
|
22
|
+
const parseResult = RunAgentInputSchema.safeParse(body);
|
|
23
|
+
if (!parseResult.success) {
|
|
24
|
+
return Promise.reject(
|
|
25
|
+
new AGUIError(
|
|
26
|
+
`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: ${parseResult.error.message}`
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const parsed = parseResult.data;
|
|
31
|
+
const rawMessages = body.messages ?? [];
|
|
32
|
+
const messages = parsed.messages.map((m, i) => {
|
|
33
|
+
const raw = rawMessages[i];
|
|
34
|
+
if (raw && typeof raw === "object" && "parts" in raw && isValidParts(raw.parts)) {
|
|
35
|
+
return { ...m, parts: raw.parts };
|
|
36
|
+
}
|
|
37
|
+
return m;
|
|
38
|
+
});
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
messages,
|
|
41
|
+
threadId: parsed.threadId,
|
|
42
|
+
runId: parsed.runId,
|
|
43
|
+
parentRunId: parsed.parentRunId,
|
|
44
|
+
tools: parsed.tools,
|
|
45
|
+
forwardedProps: parsed.forwardedProps ?? {},
|
|
46
|
+
state: parsed.state,
|
|
47
|
+
context: parsed.context
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async function chatParamsFromRequest(req) {
|
|
51
|
+
let body;
|
|
52
|
+
try {
|
|
53
|
+
body = await req.json();
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
const res = new Response(
|
|
56
|
+
"Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.",
|
|
57
|
+
{ status: 400 }
|
|
58
|
+
);
|
|
59
|
+
res.cause = cause;
|
|
60
|
+
throw res;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return await chatParamsFromRequestBody(body);
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
const res = new Response(
|
|
66
|
+
"Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.",
|
|
67
|
+
{ status: 400 }
|
|
68
|
+
);
|
|
69
|
+
res.cause = cause;
|
|
70
|
+
throw res;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function mergeAgentTools(serverTools, clientTools) {
|
|
74
|
+
const seen = new Set(serverTools.map((t) => t.name));
|
|
75
|
+
const merged = [...serverTools];
|
|
76
|
+
for (const ct of clientTools) {
|
|
77
|
+
if (seen.has(ct.name)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
seen.add(ct.name);
|
|
81
|
+
merged.push({
|
|
82
|
+
name: ct.name,
|
|
83
|
+
description: ct.description,
|
|
84
|
+
inputSchema: ct.parameters
|
|
85
|
+
// No `execute` — runtime treats this as a client-side tool and
|
|
86
|
+
// emits ClientToolRequest events.
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
chatParamsFromRequest,
|
|
93
|
+
chatParamsFromRequestBody,
|
|
94
|
+
mergeAgentTools
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=chat-params.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chat-params.js","sources":["../../../src/utilities/chat-params.ts"],"sourcesContent":["import { AGUIError, RunAgentInputSchema } from '@ag-ui/core'\nimport type { Context as AGUIContext } from '@ag-ui/core'\nimport type { JSONSchema, ModelMessage, Tool, UIMessage } from '../types'\n\nconst KNOWN_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n 'tool-call',\n 'tool-result',\n 'thinking',\n])\n\nfunction isValidParts(value: unknown): value is Array<{ type: string }> {\n if (!Array.isArray(value)) return false\n for (const p of value) {\n if (!p || typeof p !== 'object') return false\n const type = (p as { type?: unknown }).type\n if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false\n }\n return true\n}\n\n/**\n * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.\n *\n * Returns a spread-friendly object whose `messages` field is suitable for\n * passing directly to `chat({ messages })`. The existing\n * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and\n * reasoning/activity/developer-role normalization internally.\n *\n * @throws An error with a migration-pointing message when the body does\n * not conform to AG-UI 0.0.52 `RunAgentInputSchema`. Surface this as a\n * 400 Bad Request to the client.\n */\nexport function chatParamsFromRequestBody(body: unknown): Promise<{\n messages: Array<UIMessage | ModelMessage>\n threadId: string\n runId: string\n parentRunId?: string\n tools: Array<{ name: string; description: string; parameters: JSONSchema }>\n forwardedProps: Record<string, unknown>\n state: unknown\n context: Array<AGUIContext>\n}> {\n const parseResult = RunAgentInputSchema.safeParse(body)\n if (!parseResult.success) {\n return Promise.reject(\n new AGUIError(\n `Request body is not a valid AG-UI RunAgentInput. ` +\n `If you're upgrading from a previous @tanstack/ai-client release, ` +\n `see docs/migration/ag-ui-compliance.md. ` +\n `Validation errors: ${parseResult.error.message}`,\n ),\n )\n }\n\n const parsed = parseResult.data\n\n // AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are\n // dropped during parse. We re-attach them from the original body so the\n // existing UIMessage path inside `chat()` can use them directly.\n const rawMessages =\n (body as { messages?: Array<Record<string, unknown>> }).messages ?? []\n const messages = parsed.messages.map((m, i) => {\n const raw = rawMessages[i]\n if (\n raw &&\n typeof raw === 'object' &&\n 'parts' in raw &&\n isValidParts(raw.parts)\n ) {\n return { ...m, parts: raw.parts } as UIMessage | ModelMessage\n }\n return m as ModelMessage\n })\n\n return Promise.resolve({\n messages,\n threadId: parsed.threadId,\n runId: parsed.runId,\n parentRunId: parsed.parentRunId,\n tools: parsed.tools as Array<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n forwardedProps: (parsed.forwardedProps ?? {}) as Record<string, unknown>,\n state: parsed.state,\n context: parsed.context,\n })\n}\n\n/**\n * Read an HTTP `Request`, parse its JSON body, and validate it as an\n * AG-UI `RunAgentInput` — collapsing the standard `req.json()` +\n * `chatParamsFromRequestBody(...)` pair into a single call.\n *\n * On a malformed body or invalid AG-UI shape, this **throws a\n * `Response`** with status 400 and a migration-pointing message in the\n * body. Frameworks that natively handle thrown `Response` objects\n * (TanStack Start, SolidStart, Remix, React Router 7) will return the\n * 400 to the client automatically, so the handler reduces to:\n *\n * ```ts\n * export async function POST(req: Request) {\n * const params = await chatParamsFromRequest(req)\n * // ...use params\n * }\n * ```\n *\n * In frameworks that do not auto-handle thrown `Response` objects\n * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call\n * with try/catch and return the caught Response yourself, or use\n * `chatParamsFromRequestBody` directly with your own JSON-parsing.\n *\n * @throws {Response} 400 on malformed JSON or invalid AG-UI shape.\n */\nexport async function chatParamsFromRequest(\n req: Request,\n): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>> {\n let body: unknown\n try {\n body = await req.json()\n } catch (cause) {\n // Preserve the underlying error on the thrown Response for\n // server-side observability without leaking it to the client.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as unknown as { cause?: unknown }).cause = cause\n throw res\n }\n try {\n return await chatParamsFromRequestBody(body)\n } catch (cause) {\n // Generic public message — avoid echoing Zod paths (which can contain\n // user payload fragments) or internal validator strings to the client.\n // The original AGUIError is attached as `cause` so server logs can\n // surface it without exposing it to remote callers.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as unknown as { cause?: unknown }).cause = cause\n throw res\n }\n}\n\n/**\n * Merge a server-side tool array with the AG-UI client-declared tools\n * received in the request body.\n *\n * Rules:\n * - Server tools win on name collision. The client's declaration is\n * ignored if the server already has a tool with that name. The client's\n * UI-side handler still fires when the streamed tool-result event comes\n * through (see `chat-client.ts` `onToolCall`), giving the\n * \"after server execution the client also handles\" semantic for free.\n * - Client-only tools (name not in `serverTools`) become no-execute\n * entries: the runtime's existing `ClientToolRequest` path handles\n * them — server emits a tool-call request, client executes via its\n * registered handler, client posts back the result.\n *\n * @param serverTools - The server's tool array (e.g. from\n * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.\n * @param clientTools - The `tools` array received from\n * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.\n * @returns A merged array suitable for `chat({ tools })`.\n */\nexport function mergeAgentTools(\n serverTools: ReadonlyArray<Tool>,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): Array<Tool> {\n const seen = new Set(serverTools.map((t) => t.name))\n const merged: Array<Tool> = [...serverTools]\n for (const ct of clientTools) {\n if (seen.has(ct.name)) {\n // Server wins on name collision.\n continue\n }\n seen.add(ct.name)\n merged.push({\n name: ct.name,\n description: ct.description,\n inputSchema: ct.parameters,\n // No `execute` — runtime treats this as a client-side tool and\n // emits ClientToolRequest events.\n } as Tool)\n }\n return merged\n}\n"],"names":[],"mappings":";AAIA,MAAM,uCAAuB,IAAI;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,SAAS,aAAa,OAAkD;AACtE,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO;AAClC,aAAW,KAAK,OAAO;AACrB,QAAI,CAAC,KAAK,OAAO,MAAM,SAAU,QAAO;AACxC,UAAM,OAAQ,EAAyB;AACvC,QAAI,OAAO,SAAS,YAAY,CAAC,iBAAiB,IAAI,IAAI,EAAG,QAAO;AAAA,EACtE;AACA,SAAO;AACT;AAcO,SAAS,0BAA0B,MASvC;AACD,QAAM,cAAc,oBAAoB,UAAU,IAAI;AACtD,MAAI,CAAC,YAAY,SAAS;AACxB,WAAO,QAAQ;AAAA,MACb,IAAI;AAAA,QACF,gLAGwB,YAAY,MAAM,OAAO;AAAA,MAAA;AAAA,IACnD;AAAA,EAEJ;AAEA,QAAM,SAAS,YAAY;AAK3B,QAAM,cACH,KAAuD,YAAY,CAAA;AACtE,QAAM,WAAW,OAAO,SAAS,IAAI,CAAC,GAAG,MAAM;AAC7C,UAAM,MAAM,YAAY,CAAC;AACzB,QACE,OACA,OAAO,QAAQ,YACf,WAAW,OACX,aAAa,IAAI,KAAK,GACtB;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,IAAI,MAAA;AAAA,IAC5B;AACA,WAAO;AAAA,EACT,CAAC;AAED,SAAO,QAAQ,QAAQ;AAAA,IACrB;AAAA,IACA,UAAU,OAAO;AAAA,IACjB,OAAO,OAAO;AAAA,IACd,aAAa,OAAO;AAAA,IACpB,OAAO,OAAO;AAAA,IAKd,gBAAiB,OAAO,kBAAkB,CAAA;AAAA,IAC1C,OAAO,OAAO;AAAA,IACd,SAAS,OAAO;AAAA,EAAA,CACjB;AACH;AA2BA,eAAsB,sBACpB,KACgE;AAChE,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,IAAI,KAAA;AAAA,EACnB,SAAS,OAAO;AAGd,UAAM,MAAM,IAAI;AAAA,MACd;AAAA,MACA,EAAE,QAAQ,IAAA;AAAA,IAAI;AAEd,QAAuC,QAAQ;AACjD,UAAM;AAAA,EACR;AACA,MAAI;AACF,WAAO,MAAM,0BAA0B,IAAI;AAAA,EAC7C,SAAS,OAAO;AAKd,UAAM,MAAM,IAAI;AAAA,MACd;AAAA,MACA,EAAE,QAAQ,IAAA;AAAA,IAAI;AAEd,QAAuC,QAAQ;AACjD,UAAM;AAAA,EACR;AACF;AAuBO,SAAS,gBACd,aACA,aAKa;AACb,QAAM,OAAO,IAAI,IAAI,YAAY,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACnD,QAAM,SAAsB,CAAC,GAAG,WAAW;AAC3C,aAAW,MAAM,aAAa;AAC5B,QAAI,KAAK,IAAI,GAAG,IAAI,GAAG;AAErB;AAAA,IACF;AACA,SAAK,IAAI,GAAG,IAAI;AAChB,WAAO,KAAK;AAAA,MACV,MAAM,GAAG;AAAA,MACT,aAAa,GAAG;AAAA,MAChB,aAAa,GAAG;AAAA;AAAA;AAAA,IAAA,CAGT;AAAA,EACX;AACA,SAAO;AACT;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Core TanStack AI library - Open source AI SDK",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"tanstack-intent"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@ag-ui/core": "0.0.
|
|
56
|
+
"@ag-ui/core": "^0.0.52",
|
|
57
57
|
"partial-json": "^0.1.7",
|
|
58
|
-
"@tanstack/ai-event-client": "0.3.
|
|
58
|
+
"@tanstack/ai-event-client": "0.3.2"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@opentelemetry/api": ">=1.9.0"
|
|
@@ -39,6 +39,47 @@ export async function POST(request: Request) {
|
|
|
39
39
|
typed AG-UI event (discriminated union on `type`). The `toServerSentEventsResponse()`
|
|
40
40
|
helper encodes that iterable into an SSE-formatted `Response` with correct headers.
|
|
41
41
|
|
|
42
|
+
## Setup — Receiving AG-UI RunAgentInput on the Server
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import {
|
|
46
|
+
chat,
|
|
47
|
+
chatParamsFromRequestBody,
|
|
48
|
+
mergeAgentTools,
|
|
49
|
+
toServerSentEventsResponse,
|
|
50
|
+
} from '@tanstack/ai'
|
|
51
|
+
import { openaiText } from '@tanstack/ai-openai/adapters'
|
|
52
|
+
import { serverTools } from './tools'
|
|
53
|
+
|
|
54
|
+
export async function POST(req: Request) {
|
|
55
|
+
let params
|
|
56
|
+
try {
|
|
57
|
+
params = await chatParamsFromRequestBody(await req.json())
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return new Response(
|
|
60
|
+
error instanceof Error ? error.message : 'Bad request',
|
|
61
|
+
{ status: 400 },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stream = chat({
|
|
66
|
+
adapter: openaiText('gpt-4o'),
|
|
67
|
+
messages: params.messages,
|
|
68
|
+
tools: mergeAgentTools(serverTools, params.tools),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return toServerSentEventsResponse(stream)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core`. `mergeAgentTools` merges the server's tool registry with client-declared tools (server wins on collision; client-only tools become no-execute stubs that flow through the runtime's `ClientToolRequest` path).
|
|
76
|
+
|
|
77
|
+
`params.messages` is a mixed array of TanStack `UIMessage` anchors (with `parts`) and AG-UI fan-out duplicates (`{role:'tool',...}`, `{role:'reasoning',...}`). The existing `convertMessagesToModelMessages` (called inside `chat()`) handles dedup automatically.
|
|
78
|
+
|
|
79
|
+
**Wire shape (POST body):** AG-UI `RunAgentInput` — `{threadId, runId, parentRunId?, state, messages, tools, context, forwardedProps}`. The `messages` array carries TanStack `UIMessage` anchors with their canonical `parts` plus AG-UI mirror fields (`content`, `toolCalls`) inline; tool results and thinking parts are additionally emitted as fan-out `{role:'tool',...}` and `{role:'reasoning',...}` entries.
|
|
80
|
+
|
|
81
|
+
**`forwardedProps` security:** Don't spread it directly into `chat()` — clients could override `adapter`, `model`, `tools`, etc. Always allowlist specific fields.
|
|
82
|
+
|
|
42
83
|
## Core Patterns
|
|
43
84
|
|
|
44
85
|
### 1. SSE Format — toServerSentEventsStream / toServerSentEventsResponse
|
|
@@ -223,9 +264,11 @@ Source: docs/protocol/chunk-definitions.md
|
|
|
223
264
|
|
|
224
265
|
## Tension
|
|
225
266
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
267
|
+
RESOLVED: TanStack AI is fully AG-UI compliant on both axes (server→client events
|
|
268
|
+
AND client→server `RunAgentInput`). The wire format carries TanStack `UIMessage`
|
|
269
|
+
anchors with their parts intact alongside AG-UI fan-out messages, so strict AG-UI
|
|
270
|
+
servers see role-based messages while TanStack-aware servers read parts directly
|
|
271
|
+
without transformation. See `docs/migration/ag-ui-compliance.md` for details.
|
|
229
272
|
|
|
230
273
|
## Cross-References
|
|
231
274
|
|
|
@@ -4,7 +4,9 @@ description: >
|
|
|
4
4
|
Type-safe JSON schema responses from LLMs using outputSchema on chat().
|
|
5
5
|
Supports Zod, ArkType, and Valibot schemas. The adapter handles
|
|
6
6
|
provider-specific strategies transparently — never configure structured
|
|
7
|
-
output at the provider level.
|
|
7
|
+
output at the provider level. Pass stream:true alongside outputSchema for
|
|
8
|
+
incremental JSON deltas + a terminal validated object via the
|
|
9
|
+
`structured-output.complete` event. convertSchemaToJsonSchema() for manual
|
|
8
10
|
schema conversion.
|
|
9
11
|
type: sub-skill
|
|
10
12
|
library: tanstack-ai
|
|
@@ -46,6 +48,8 @@ const stream = chat({
|
|
|
46
48
|
|
|
47
49
|
When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed based on the schema.
|
|
48
50
|
|
|
51
|
+
Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below.
|
|
52
|
+
|
|
49
53
|
## Core Patterns
|
|
50
54
|
|
|
51
55
|
### Pattern 1: Basic structured output with Zod
|
|
@@ -128,8 +132,94 @@ console.log(company.employees[0].role)
|
|
|
128
132
|
console.log(company.financials?.revenue)
|
|
129
133
|
```
|
|
130
134
|
|
|
135
|
+
### Pattern 3: Streaming structured output
|
|
136
|
+
|
|
137
|
+
Pass `stream: true` alongside `outputSchema` to receive incremental JSON deltas while the model generates, plus a final validated typed object. Useful for streaming partial UI (progress views, typewriter previews, partially-filled forms).
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { chat } from '@tanstack/ai'
|
|
141
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
142
|
+
import { z } from 'zod'
|
|
143
|
+
|
|
144
|
+
const PersonSchema = z.object({
|
|
145
|
+
name: z.string(),
|
|
146
|
+
age: z.number(),
|
|
147
|
+
email: z.string().email(),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const stream = chat({
|
|
151
|
+
adapter: openaiText('gpt-5.2'),
|
|
152
|
+
messages: [
|
|
153
|
+
{ role: 'user', content: 'Extract: John Doe is 30, john@example.com' },
|
|
154
|
+
],
|
|
155
|
+
outputSchema: PersonSchema,
|
|
156
|
+
stream: true,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
let raw = ''
|
|
160
|
+
for await (const chunk of stream) {
|
|
161
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
162
|
+
// Partial JSON text — drive progress UI only. Do NOT JSON.parse.
|
|
163
|
+
raw += chunk.delta
|
|
164
|
+
} else if (
|
|
165
|
+
chunk.type === 'CUSTOM' &&
|
|
166
|
+
chunk.name === 'structured-output.complete'
|
|
167
|
+
) {
|
|
168
|
+
// Terminal event. `chunk.value.object` is fully validated and typed
|
|
169
|
+
// against the schema you passed in — no helper or cast required.
|
|
170
|
+
chunk.value.object.name // string
|
|
171
|
+
chunk.value.object.age // number
|
|
172
|
+
chunk.value.reasoning // string | undefined (thinking models only)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through to the terminal event, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper needed.
|
|
178
|
+
|
|
179
|
+
**Adapter coverage for streaming:**
|
|
180
|
+
|
|
181
|
+
| Adapter | `outputSchema` + `stream: true` |
|
|
182
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
183
|
+
| `@tanstack/ai-openai` | Native single-request stream (Responses API) |
|
|
184
|
+
| `@tanstack/ai-openrouter` | Native single-request stream |
|
|
185
|
+
| `@tanstack/ai-grok` | Native single-request stream (Chat Completions) |
|
|
186
|
+
| `@tanstack/ai-groq` | Native single-request stream (Chat Completions) |
|
|
187
|
+
| All other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
|
|
188
|
+
|
|
189
|
+
The consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental deltas when the adapter implements `structuredOutputStream` natively.
|
|
190
|
+
|
|
131
191
|
## Common Mistakes
|
|
132
192
|
|
|
193
|
+
### HIGH: Parsing streaming JSON deltas yourself
|
|
194
|
+
|
|
195
|
+
When using `chat({ outputSchema, stream: true })`, the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
199
|
+
for await (const chunk of stream) {
|
|
200
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
201
|
+
const obj = JSON.parse(chunk.delta) // ❌ partial, invalid
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// CORRECT -- accumulate deltas only for UX progress; trust the terminal event
|
|
206
|
+
let raw = ''
|
|
207
|
+
for await (const chunk of stream) {
|
|
208
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
209
|
+
raw += chunk.delta // optional: render a "streaming JSON" preview
|
|
210
|
+
} else if (
|
|
211
|
+
chunk.type === 'CUSTOM' &&
|
|
212
|
+
chunk.name === 'structured-output.complete'
|
|
213
|
+
) {
|
|
214
|
+
const result = chunk.value.object // ✅ typed and validated
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
If you need progressive _parsed_ state (e.g. show fields as they arrive), use a partial-JSON parser on the accumulated `raw` string at render time — but do NOT treat the result as schema-validated; only the terminal event is.
|
|
220
|
+
|
|
221
|
+
Source: maintainer interview
|
|
222
|
+
|
|
133
223
|
### HIGH: Trying to implement provider-specific structured output strategies
|
|
134
224
|
|
|
135
225
|
The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
|
|
@@ -201,3 +291,4 @@ Source: maintainer interview
|
|
|
201
291
|
## Cross-References
|
|
202
292
|
|
|
203
293
|
- See also: ai-core/adapter-configuration/SKILL.md -- Adapter handles structured output strategy transparently
|
|
294
|
+
- See also: ai-core/chat-experience/SKILL.md -- Consuming `StreamChunk` events on the client (the streaming variant uses the same chunk model plus the terminal `structured-output.complete` custom event)
|
|
@@ -100,6 +100,23 @@ export interface TextAdapter<
|
|
|
100
100
|
structuredOutput: (
|
|
101
101
|
options: StructuredOutputOptions<TProviderOptions>,
|
|
102
102
|
) => Promise<StructuredOutputResult<unknown>>
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stream structured output using the provider's native streaming structured
|
|
106
|
+
* output API (stream + response_format json_schema in a single request).
|
|
107
|
+
*
|
|
108
|
+
* Optional — adapters without native streaming JSON omit this method and the
|
|
109
|
+
* activity layer synthesizes a stream around the non-streaming
|
|
110
|
+
* `structuredOutput` call.
|
|
111
|
+
*
|
|
112
|
+
* Implementations must emit standard AG-UI lifecycle events (RUN_STARTED,
|
|
113
|
+
* TEXT_MESSAGE_*, RUN_FINISHED) carrying raw JSON text deltas, plus a final
|
|
114
|
+
* `CUSTOM` event named `structured-output.complete` whose `value` is
|
|
115
|
+
* `{ object, raw, reasoning? }`.
|
|
116
|
+
*/
|
|
117
|
+
structuredOutputStream?: (
|
|
118
|
+
options: StructuredOutputOptions<TProviderOptions>,
|
|
119
|
+
) => AsyncIterable<StreamChunk>
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
/**
|