📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# ToolLoopAgent Patterns
|
||||
|
||||
Deep dive on v6 agent workflows.
|
||||
|
||||
## When to Use ToolLoopAgent
|
||||
|
||||
| Use Case | Approach |
|
||||
|----------|----------|
|
||||
| Single tool call | `streamText` with tools |
|
||||
| Multi-step reasoning | `ToolLoopAgent` |
|
||||
| Autonomous workflows | `ToolLoopAgent` with `stopWhen` |
|
||||
| Complex orchestration | `ToolLoopAgent` with custom stop conditions |
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
```typescript
|
||||
import { ToolLoopAgent, stepCountIs } from 'ai';
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
// Required
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
|
||||
// Optional
|
||||
instructions: 'You are a research assistant.',
|
||||
tools: { search, calculate, summarize },
|
||||
|
||||
// Stop conditions
|
||||
stopWhen: stepCountIs(10),
|
||||
// Or custom: async ({ steps }) => steps.length >= 10
|
||||
|
||||
// Tool selection
|
||||
toolChoice: 'auto', // 'auto' | 'required' | 'none'
|
||||
|
||||
// Token limits
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
```
|
||||
|
||||
## Execution Patterns
|
||||
|
||||
### Non-Streaming (Simple)
|
||||
|
||||
```typescript
|
||||
const result = await agent.generate({
|
||||
prompt: 'Research quantum computing breakthroughs.',
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
console.log(result.steps); // Array of all steps
|
||||
console.log(result.steps.length, 'steps executed');
|
||||
```
|
||||
|
||||
### Streaming (Real-time)
|
||||
|
||||
```typescript
|
||||
const stream = agent.stream({
|
||||
prompt: 'Analyze this data and provide insights.',
|
||||
});
|
||||
|
||||
for await (const chunk of stream.textStream) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
### UI Streaming (React/Next.js)
|
||||
|
||||
```typescript
|
||||
import { createAgentUIStream } from 'ai';
|
||||
|
||||
const stream = await createAgentUIStream({
|
||||
agent,
|
||||
messages: [{ role: 'user', content: 'What is the weather?' }],
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Yield to client
|
||||
}
|
||||
```
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
### Built-in: Step Count
|
||||
|
||||
```typescript
|
||||
import { stepCountIs } from 'ai';
|
||||
|
||||
stopWhen: stepCountIs(5) // Stop after 5 steps
|
||||
```
|
||||
|
||||
### Custom: Finish Reason
|
||||
|
||||
```typescript
|
||||
stopWhen: async ({ steps }) =>
|
||||
steps.at(-1)?.finishReason === 'stop'
|
||||
```
|
||||
|
||||
### Custom: Combined
|
||||
|
||||
```typescript
|
||||
stopWhen: async ({ steps }) =>
|
||||
steps.length >= 10 || steps.at(-1)?.finishReason === 'stop'
|
||||
```
|
||||
|
||||
## Tool Choice Control
|
||||
|
||||
```typescript
|
||||
const agent = new ToolLoopAgent({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
tools: { search, calculate },
|
||||
|
||||
// Force tool use every step
|
||||
toolChoice: 'required',
|
||||
|
||||
// Disable tools (text only)
|
||||
toolChoice: 'none',
|
||||
|
||||
// Let model decide (default)
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
```
|
||||
|
||||
## Agent with Constraints
|
||||
|
||||
```typescript
|
||||
const customerSupportAgent = new ToolLoopAgent({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
instructions: `You are a customer support specialist.
|
||||
|
||||
Rules:
|
||||
- Never promise refunds without checking policy
|
||||
- Always be empathetic and professional
|
||||
- If unsure, offer to escalate
|
||||
- Keep responses concise
|
||||
- Never share internal company information`,
|
||||
tools: {
|
||||
checkOrderStatus,
|
||||
lookupPolicy,
|
||||
createTicket,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Accessing Step History
|
||||
|
||||
```typescript
|
||||
const result = await agent.generate({ prompt: '...' });
|
||||
|
||||
for (const step of result.steps) {
|
||||
if (step.type === 'tool-call') {
|
||||
console.log(`Called ${step.tool} with`, step.input);
|
||||
} else if (step.type === 'text-generation') {
|
||||
console.log('Generated:', step.output);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await agent.generate({ prompt: '...' });
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Agent execution aborted');
|
||||
} else {
|
||||
console.error('Agent error:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**DO:**
|
||||
- Set reasonable `stopWhen` limits
|
||||
- Use typed tool schemas with Zod
|
||||
- Handle abort signals for long-running agents
|
||||
- Log step history for debugging
|
||||
|
||||
**DON'T:**
|
||||
- Leave agents unbounded (no stop condition)
|
||||
- Use synchronous blocking operations in tools
|
||||
- Ignore error states
|
||||
- Skip tool validation
|
||||
@@ -0,0 +1,259 @@
|
||||
# Chat Persistence
|
||||
|
||||
Strategies for persisting chat messages with AI SDK v6.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Persist `UIMessage[]`, convert to `ModelMessage[]` only at call sites.**
|
||||
|
||||
```typescript
|
||||
// Database stores UIMessage format
|
||||
const chat = await loadChat(chatId); // Returns UIMessage[]
|
||||
|
||||
// Convert only when calling the model
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(chat.messages),
|
||||
});
|
||||
```
|
||||
|
||||
## Server-Side Persistence
|
||||
|
||||
### onFinish Callback
|
||||
|
||||
```typescript
|
||||
import { streamText, createIdGenerator } from 'ai';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages, chatId } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
|
||||
// Generate stable, server-side message IDs
|
||||
generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
|
||||
|
||||
// Persist after stream completes
|
||||
onFinish: async ({ messages: completeMessages }) => {
|
||||
await db.chat.upsert({
|
||||
where: { id: chatId },
|
||||
update: { messages: completeMessages, updatedAt: new Date() },
|
||||
create: { id: chatId, messages: completeMessages },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Survive Client Disconnects
|
||||
|
||||
Call `consumeStream()` to ensure the stream completes even if the client disconnects:
|
||||
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
// Start consuming immediately (runs in background)
|
||||
result.consumeStream();
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
onFinish: async ({ messages }) => {
|
||||
await saveChat(chatId, messages);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Drizzle Example
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, jsonb, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const chats = pgTable('chats', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
title: text('title'),
|
||||
messages: jsonb('messages').$type<UIMessage[]>().notNull().default([]),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### Prisma Example
|
||||
|
||||
```prisma
|
||||
model Chat {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String?
|
||||
messages Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
## Loading Chats
|
||||
|
||||
```typescript
|
||||
// API route to load chat
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
const chat = await db.chat.findUnique({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: chat.id,
|
||||
messages: chat.messages as UIMessage[],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Client-side loading
|
||||
'use client';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function Chat({ chatId }: { chatId: string }) {
|
||||
const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/chats/${chatId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setInitialMessages(data.messages);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [chatId]);
|
||||
|
||||
const { messages, sendMessage } = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return <ChatUI messages={messages} onSend={sendMessage} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Optimization
|
||||
|
||||
Send only the last message, load history server-side:
|
||||
|
||||
```typescript
|
||||
// Client: Send only new message
|
||||
const { sendMessage } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
prepareSendMessagesRequest: ({ id, messages }) => ({
|
||||
body: {
|
||||
chatId: id,
|
||||
message: messages.at(-1) // Only send last message
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Server: Load history from database
|
||||
export async function POST(req: Request) {
|
||||
const { chatId, message } = await req.json();
|
||||
|
||||
// Load existing messages from database
|
||||
const chat = await db.chat.findUnique({ where: { id: chatId } });
|
||||
const messages = [...(chat?.messages ?? []), message];
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
onFinish: async ({ messages }) => {
|
||||
await db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Resumable Streams
|
||||
|
||||
Resume interrupted streams on page reload:
|
||||
|
||||
```tsx
|
||||
function Chat({ chatId }: { chatId: string }) {
|
||||
const { messages, resumeStream, status } = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
// Resume on mount if there's an incomplete stream
|
||||
useEffect(() => {
|
||||
const lastMessage = messages.at(-1);
|
||||
if (lastMessage?.role === 'assistant' && status === 'ready') {
|
||||
// Check if message seems incomplete
|
||||
resumeStream();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ... */}
|
||||
{status === 'streaming' && <div>AI is typing...</div>}
|
||||
<button onClick={() => resumeStream()}>Resume</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from v4/v5
|
||||
|
||||
If migrating existing data, use the dual-write pattern:
|
||||
|
||||
1. Create new `messages_v6` table
|
||||
2. Dual-write to both tables
|
||||
3. Run background migration
|
||||
4. Switch reads to v6 schema
|
||||
5. Remove dual-write
|
||||
6. Drop old table
|
||||
|
||||
See [AI SDK Migration Guide](https://sdk.vercel.ai/docs/migration-guides) for detailed steps.
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Persistence:**
|
||||
- Always use `onFinish` for reliable persistence
|
||||
- Generate server-side message IDs for consistency
|
||||
- Use `consumeStream()` to complete streams even on disconnect
|
||||
|
||||
**Performance:**
|
||||
- Index by userId for user-specific queries
|
||||
- Consider pagination for long conversations
|
||||
- Use bandwidth optimization for mobile clients
|
||||
|
||||
**Reliability:**
|
||||
- Handle concurrent updates with optimistic locking
|
||||
- Implement retry logic for database failures
|
||||
- Log persistence errors for debugging
|
||||
@@ -0,0 +1,250 @@
|
||||
# Tool Approval (Human-in-the-Loop)
|
||||
|
||||
v6 patterns for requiring user approval before tool execution.
|
||||
|
||||
## When to Use
|
||||
|
||||
| Scenario | Approval Type |
|
||||
|----------|---------------|
|
||||
| Always dangerous (delete, payment) | Static: `needsApproval: true` |
|
||||
| Conditionally risky (large amounts) | Dynamic: `needsApproval: async (args) => boolean` |
|
||||
| User preference | Dynamic based on user settings |
|
||||
|
||||
## Static Approval
|
||||
|
||||
Tool always requires approval:
|
||||
|
||||
```typescript
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const deleteUserTool = tool({
|
||||
description: 'Permanently delete a user account',
|
||||
inputSchema: z.object({
|
||||
userId: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
needsApproval: true, // Always require
|
||||
execute: async ({ userId, reason }) => {
|
||||
await deleteUser(userId, reason);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Approval
|
||||
|
||||
Approval based on input:
|
||||
|
||||
```typescript
|
||||
const paymentTool = tool({
|
||||
description: 'Process a payment',
|
||||
inputSchema: z.object({
|
||||
amount: z.number(),
|
||||
recipient: z.string(),
|
||||
currency: z.string().default('USD'),
|
||||
}),
|
||||
needsApproval: async ({ amount }) => amount > 1000,
|
||||
execute: async ({ amount, recipient, currency }) => {
|
||||
return await processPayment(amount, recipient, currency);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Complex Approval Logic
|
||||
|
||||
```typescript
|
||||
const externalApiTool = tool({
|
||||
description: 'Call external API',
|
||||
inputSchema: z.object({
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'DELETE']),
|
||||
body: z.any().optional(),
|
||||
}),
|
||||
needsApproval: async ({ method, endpoint }) => {
|
||||
// Approve all non-GET requests
|
||||
if (method !== 'GET') return true;
|
||||
|
||||
// Approve requests to sensitive endpoints
|
||||
if (endpoint.includes('/admin')) return true;
|
||||
|
||||
// No approval needed for safe reads
|
||||
return false;
|
||||
},
|
||||
execute: async ({ endpoint, method, body }) => {
|
||||
return await fetch(endpoint, { method, body: JSON.stringify(body) });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Client-Side Handling
|
||||
|
||||
### useChat with Approval
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
|
||||
function Chat() {
|
||||
const { messages, sendMessage, addToolApprovalResponse } = useChat({
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
onApprove={(id) => addToolApprovalResponse({ id, approved: true })}
|
||||
onDeny={(id) => addToolApprovalResponse({ id, approved: false })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Approval UI Component
|
||||
|
||||
```tsx
|
||||
function ToolInvocation({ invocation, onApprove, onDeny }) {
|
||||
switch (invocation.state) {
|
||||
case 'approval-requested':
|
||||
return (
|
||||
<div className="border rounded p-4 bg-yellow-50">
|
||||
<h4 className="font-bold">Approval Required</h4>
|
||||
<p>Tool: {invocation.toolName}</p>
|
||||
<pre className="text-sm bg-gray-100 p-2 rounded">
|
||||
{JSON.stringify(invocation.input, null, 2)}
|
||||
</pre>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => onApprove(invocation.approval.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeny(invocation.approval.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pending':
|
||||
return <div className="text-gray-500">Waiting for tool execution...</div>;
|
||||
|
||||
case 'output-available':
|
||||
return (
|
||||
<div className="border rounded p-4 bg-green-50">
|
||||
<h4 className="font-bold">Tool Result</h4>
|
||||
<pre className="text-sm">{JSON.stringify(invocation.output, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Tool Parts in Messages
|
||||
|
||||
```tsx
|
||||
function Message({ message, onApprove, onDeny }) {
|
||||
return (
|
||||
<div>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return <p key={i}>{part.text}</p>;
|
||||
}
|
||||
|
||||
if (part.type === 'tool-invocation') {
|
||||
return (
|
||||
<ToolInvocation
|
||||
key={i}
|
||||
invocation={part}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Processing
|
||||
|
||||
For complex approval workflows with server-side tool execution:
|
||||
|
||||
```typescript
|
||||
import { processToolCalls } from './tool-processor';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
// Check for pending approvals
|
||||
const lastMessage = messages.at(-1);
|
||||
const hasPendingApprovals = lastMessage?.parts?.some(
|
||||
(p) => p.type === 'tool-invocation' && p.state === 'output-available'
|
||||
);
|
||||
|
||||
if (hasPendingApprovals) {
|
||||
// Process approved tools
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const processed = await processToolCalls({
|
||||
writer,
|
||||
messages,
|
||||
tools: myTools,
|
||||
}, toolExecuteFunctions);
|
||||
|
||||
// Continue conversation with tool results
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(processed),
|
||||
});
|
||||
|
||||
writer.merge(result.toUIMessageStream());
|
||||
},
|
||||
originalMessages: messages,
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
|
||||
// Normal flow
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: convertToModelMessages(messages),
|
||||
tools: myTools,
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({ originalMessages: messages });
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Security:**
|
||||
- Always require approval for destructive operations
|
||||
- Use dynamic approval for operations with varying risk levels
|
||||
- Log all approval decisions for audit trails
|
||||
|
||||
**UX:**
|
||||
- Show clear context for what the tool will do
|
||||
- Display input parameters so users can make informed decisions
|
||||
- Provide cancel/timeout options for pending approvals
|
||||
|
||||
**Error Handling:**
|
||||
- Handle denied approvals gracefully
|
||||
- Provide alternative actions when tools are denied
|
||||
- Don't retry denied tools automatically
|
||||
Reference in New Issue
Block a user