AI SDK 6 задаёт для приложения на TypeScript понятную границу между решениями модели и полномочиями приложения. Модель может выбрать типизированный инструмент, получить его результат и продолжить диалог. При этом ваш код всё равно проверяет входные данные, применяет права, решает, нужно ли участие человека для побочного эффекта, и записывает результат. Именно это разделение лежит в основе производственного агента. В статье используются стабильные API AI SDK 6, а не более поздний API подтверждений из AI SDK 7. Установите ai@6 вместе с пакетом провайдера из совместимой ветки, например @ai-sdk/openai@3, и закрепите версии в lock-файле приложения.
Как устроен цикл
Один вызов модели может вернуть текст или вызов инструмента. Цикл инструментов добавляет новый вызов модели после завершения инструмента. Так модель интерпретирует результат и решает, нужен ли ещё один инструмент. Каждая генерация модели является шагом. Цикл завершается, когда модель перестаёт запрашивать инструменты, у вызванного инструмента нет execute, требуется подтверждение или срабатывает условие stopWhen. Результат содержит итоговый текст, вызовы и результаты инструментов, сообщения ответа, статистику использования и массив steps. Поэтому цикл можно проверять, а не считать магией.
ToolLoopAgent упаковывает это поведение в переиспользуемый объект. Конструктор требует LanguageModel и принимает instructions, tools, stopWhen, output, prepareStep, maxRetries, тайм-ауты и callback-функции. В AI SDK 6 условие остановки по умолчанию равно stepCountIs(20). Для ограниченного сценария укажите меньшее значение. Лимит управляет стоимостью и доступностью, но не заменяет проверку прав.
Типизированный ToolLoopAgent
Помощник tool выводит тип аргумента функции execute из inputSchema. Схема передаётся провайдеру и используется для проверки аргументов, которые прислала модель. Но модель всё равно не становится доверенной. Корректная форма данных может содержать чужой аккаунт, запрещённый путь, опасный URL или недопустимую сумму.
npm install [email protected] @ai-sdk/[email protected] @ai-sdk/[email protected] zod
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs, tool } from 'ai';
import { z } from 'zod';
const getWeather = tool({
description: 'Get the current weather for a city',
inputSchema: z.object({
city: z.string().min(1).max(80),
}),
execute: async ({ city }) => ({
city,
temperatureC: 18,
condition: 'cloudy',
}),
});
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Use getWeather for current weather. Never invent a tool result.',
tools: { getWeather },
stopWhen: stepCountIs(4),
maxRetries: 2,
});
const result = await agent.generate({
prompt: 'What is the weather in Kyiv?',
});
console.log(result.text);
console.log(result.steps.length);
Инструмент должен выполнять одну операцию и возвращать небольшой сериализуемый результат. В описании точно указывайте единицы измерения, права, актуальность и поведение при ошибке. Используйте strict: true, если провайдер поддерживает строгие вызовы и схема совместима. Строгий режим повышает надёжность, но не является авторизацией. toolChoice: 'auto' позволяет модели решать, required требует один доступный инструмент, none отключает инструменты, а { type: 'tool', toolName: 'getWeather' } выбирает конкретный инструмент. activeTools сужает список инструментов для отдельного шага.
Явные вызовы и лимиты цикла
Используйте generateText, когда нужен разовый вызов или прямой контроль истории сообщений. Добавьте stopWhen, если результаты инструментов нужно вернуть модели. Без него generateText делает одну генерацию и возвращает вызов инструмента, не продолжая цикл автоматически.
import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';
const lookupOrder = tool({
description: 'Look up an order by its public order number',
inputSchema: z.object({ orderNumber: z.string().regex(/^ORD-[0-9]{6}$/) }),
execute: async ({ orderNumber }) => ({
orderNumber,
status: 'shipped',
}),
});
const result = await generateText({
model: openai('gpt-4o-mini'),
tools: { lookupOrder },
stopWhen: stepCountIs(3),
prompt: 'Check order ORD-104209 and explain its status.',
});
console.log(result.text);
console.log(result.steps.flatMap(step => step.toolCalls));
Встроенные условия: stepCountIs(count), hasToolCall(toolName) и isLoopFinished(). Массив условий завершает цикл при совпадении любого из них. У isLoopFinished() нет максимума, поэтому рядом нужны внешний бюджет, тайм-аут, отмена и квота провайдера. Пользовательское StopCondition получает { steps } и может остановить цикл по состоянию бизнеса или измеренному бюджету токенов. Условия проверяются, когда последний шаг содержит результаты инструментов. При совмещении вызова инструментов со структурированным выводом оставьте дополнительный шаг для генерации вывода.
maxRetries повторяет неудачные вызовы модели. Он не делает execute идемпотентным. Инструмент, который отправляет письмо, списывает деньги или создаёт запись, должен использовать ключ идемпотентности и самостоятельно обнаруживать дубликаты. Для удалённого MCP-клиента повторы включаются отдельно через maxRetries в createMCPClient. Повторяйте сетевые ошибки и ограничения скорости, но не бездумно повторяйте неидемпотентный запрос tools/call.
Структурированный вывод для TypeScript
В AI SDK 6 функции generateObject и streamObject объявлены устаревшими. Вместо них используйте generateText и streamText с настройкой output. Output.object принимает схему Zod, Valibot или JSON Schema. Полный ответ разбирается и проверяется до разрешения result.output. Частичные объекты полезны для интерфейса, но частичный результат не является окончательной проверкой.
import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';
const reportSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
score: z.number().min(0).max(1),
keyPoints: z.array(z.string().min(1)).max(8),
});
const { output } = await generateText({
model: openai('gpt-4o-mini'),
output: Output.object({
schema: reportSchema,
name: 'review_report',
description: 'A concise, evidence-based review report',
}),
prompt: 'Analyze: The battery lasts all day, but the charger is bulky.',
});
console.log(output.sentiment, output.score, output.keyPoints);
Проверка проходит в два этапа. Провайдер может принудительно использовать формат ответа, а AI SDK разбирает JSON и проверяет его по схеме. Приложение всё равно обязано проверять бизнес-правила: например, принадлежит ли идентификатор текущему клиенту и может ли оценка запустить возврат. Делайте схемы закрытыми и узкими. Ограничивайте строки, массивы и числа, задавайте перечисления. Не передавайте произвольный JSON в привилегированный адаптер, а отклоняйте неизвестные команды.
С инструментами модель сначала может вызвать поиск, а затем создать отчёт. Укажите stopWhen: stepCountIs(4) или другой явный бюджет, потому что шаг структурированного вывода входит в тот же многошаговый поток. При ошибке разбора перехватите ошибку SDK, сохраните идентификатор корреляции и метаданные провайдера, а пользователю верните безопасный ответ с возможностью повтора. Не поручайте другой модели авторизацию повреждённого вывода.
Потоковая передача текста и событий
streamText предоставляет асинхронные потоки для текста и структурированного вывода. ToolLoopAgent.stream возвращает StreamTextResult после подготовки вызова, поэтому перед чтением textStream его нужно ожидать. Поток текста содержит генерируемый текст, а полный результат и callback-функции открывают вызовы и результаты инструментов.
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent } from 'ai';
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Answer clearly and briefly.',
});
const stream = await agent.stream({
prompt: 'Explain why typed tool inputs matter.',
});
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}
Для структурированного потока используйте streamText с Output.object и читайте partialOutputStream. Через onStepFinish удобно сохранять завершённый шаг, записывать usage и показывать событие аудита. В потоке могут появиться tool-approval-request, tool-error или tool-output-denied, а не только текст. Клиент, отображающий только текст, способен скрыть действие, которое ждёт человека.
Подтверждение действия человеком
В AI SDK 6 укажите для инструмента needsApproval, равный true или асинхронному предикату на основе проверенного входа. Первый вызов generateText или streamText возвращает часть tool-approval-request. Сервер не приостанавливается в ожидании браузера. Сохраните сообщения ответа, покажите точное имя инструмента и аргументы, добавьте tool-approval-response в новое сообщение tool и снова вызовите модель.
Ниже приведён законченный пример для Node, где границей человека служит вопрос в терминале. В веб-приложении храните messages, approvalId, toolCallId, аутентифицированного проверяющего и срок действия на сервере.
import { createInterface } from 'node:readline/promises';
import { openai } from '@ai-sdk/openai';
import {
generateText,
tool,
type ModelMessage,
type ToolApprovalResponse,
} from 'ai';
import { z } from 'zod';
const records = new Map([
['draft-17', { ownerId: 'user-7', text: 'Quarterly notes' }],
]);
const deleteDraft = tool({
description: 'Delete one draft owned by the authenticated user',
inputSchema: z.object({ draftId: z.string().regex(/^draft-[0-9]+$/) }),
needsApproval: true,
execute: async ({ draftId }) => {
if (!records.delete(draftId)) {
throw new Error('Draft was not found');
}
return { draftId, deleted: true };
},
});
async function requestHumanApproval(toolName: string, input: unknown) {
const terminal = createInterface({ input: process.stdin, output: process.stdout });
const answer = await terminal.question(`Approve ${toolName} ${JSON.stringify(input)}? [y/N] `);
terminal.close();
return answer.trim().toLowerCase() === 'y';
}
const messages: ModelMessage[] = [
{ role: 'user', content: 'Delete draft-17.' },
];
const first = await generateText({
model: openai('gpt-4o-mini'),
system: 'If an action is denied, do not retry it.',
tools: { deleteDraft },
messages,
});
messages.push(...first.response.messages);
const approvals: ToolApprovalResponse[] = [];
for (const part of first.content) {
if (part.type === 'tool-approval-request') {
approvals.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved: await requestHumanApproval(part.toolCall.toolName, part.toolCall.input),
reason: 'Decision made by the authenticated reviewer',
});
}
}
if (approvals.length > 0) {
messages.push({ role: 'tool', content: approvals });
const final = await generateText({
model: openai('gpt-4o-mini'),
system: 'If an action is denied, do not retry it.',
tools: { deleteDraft },
messages,
});
console.log(final.text);
} else {
console.log(first.text);
}
Подтверждение не заменяет авторизацию. Непосредственно перед побочным эффектом заново проверьте пользователя, клиента, цель, политику и версию ресурса. Свяжите сохранённое подтверждение с approvalId, toolCallId, именем инструмента и хешем проверенного входа. Ограничьте срок, разрешите одно использование и отклоняйте изменённый вход. Отказ должен стать понятным результатом инструмента и не должен автоматически повторяться. Для важного действия показывайте цель, личность, аргументы, данные, покидающие систему, и ожидаемый эффект, а не только пересказ модели.
Опасные инструменты и границы безопасности
Не открывайте модели общий shell, неограниченный HTTP-клиент, произвольный путь к файлу или прямое подключение к базе. Предпочитайте узкие операции вроде deleteDraft, createCalendarEvent или lookupOrder. Размещайте авторизацию и списки разрешений в execute или отдельном сервисе политик. Проверяйте схему URL, хост, порт, разрешённый адрес и перенаправление. Разрешайте пути только внутри корня и учитывайте симлинки. Владение клиента проверяйте в запросе к базе, а не только в prompt.
Считайте недоверенными описания инструментов, найденные документы, память и результаты инструментов. Prompt injection может попросить модель раскрыть секрет, вызвать посторонний инструмент или отправить данные на сайт атакующего. Модель не является границей безопасности. Принципы минимальных прав, происхождения данных, исходящего трафика, изоляции, лимитов и подтверждений разобраны в статье Prompt injection и безопасность MCP. Для разделения планировщика, исполнителя, политики и хранения полезно обратиться к архитектуре производственного AI-агента.
Никогда не помещайте ключи API в prompt или результаты инструментов. Маскируйте токены в логах и телеметрии. Не записывайте полностью вход инструмента, если он может содержать персональные данные. Передавайте идентификатор запроса через вызовы модели и инструменты. Ограничивайте размер результата до включения в следующий prompt. Разделяйте учётные данные для чтения, черновика и фиксации. Браузерный или файловый агент запускайте в изолированном worker без чужих секретов и с закрытой по умолчанию сетью.
MCP без потери типизации
Пакет @ai-sdk/mcp адаптирует инструменты MCP-сервера в инструменты AI SDK. В AI SDK 6 для production рекомендуется HTTP, а stdio предназначен для локальных серверов. Если сервер находится вне вашего контроля или инструмент чувствительный, задавайте схемы явно. Это оставляет набор инструментов узким и даёт TypeScript полезные типы входа. Установите redirect: 'error', если перенаправления запрещены политикой, проверьте источники OAuth и закрывайте клиент в finally или onFinish.
import { openai } from '@ai-sdk/openai';
import { createMCPClient } from '@ai-sdk/mcp';
import { ToolLoopAgent, stepCountIs } from 'ai';
import { z } from 'zod';
const mcpUrl = process.env.MCP_URL;
if (!mcpUrl) throw new Error('MCP_URL is required');
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: mcpUrl,
headers: process.env.MCP_TOKEN
? { Authorization: `Bearer ${process.env.MCP_TOKEN}` }
: undefined,
redirect: 'error',
},
maxRetries: 2,
});
try {
const tools = await mcpClient.tools({
schemas: {
'get-customer-note': {
inputSchema: z.object({ customerId: z.string().uuid() }),
},
},
});
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Use only the customer note tool for this request.',
tools,
stopWhen: stepCountIs(3),
});
const result = await agent.generate({ prompt: 'Read the requested customer note.' });
console.log(result.text);
} finally {
await mcpClient.close();
}
Автоматическое обнаружение через mcpClient.tools() удобно, но выставляет каждый рекламируемый инструмент и не даёт compile-time типов входа. Явные схемы загружают только названные инструменты. Повторяйте лишь временные сетевые ошибки. Ошибки приложения MCP и успешные ответы с isError: true нужно передавать модели без повторного побочного эффекта. OAuth по-прежнему требует проверки аудитории, PKCE, короткоживущих токенов, точных redirect URI и списка разрешённых authorization server. Серверная часть описана в статье MCP-сервер на TypeScript с OAuth.
Ошибки, тесты и эксплуатация
Оберните генерацию границей, различающей неверный вход, сбой провайдера, тайм-аут, отмену, повреждённый вывод и ошибку инструмента. AI SDK превращает исключение из execute в часть tool-error, чтобы многошаговая модель могла увидеть сбой. Возвращайте из инструмента очищенное сообщение или преобразуйте результат до передачи модели. Не раскрывайте stack trace, секреты, SQL, пути файлов и тела ответов upstream. Ограничивайте каждый запрос через abortSignal и timeout. Записывайте finishReason, usage, totalUsage, число шагов и решение политики инструмента.
AI SDK 6 содержит детерминированные mock-провайдеры в ai/test. MockLanguageModelV3 позволяет вернуть вызов инструмента при первой генерации и текст при второй. Следующий тест проверяет, что цикл выполняет инструмент один раз и получает финальный ответ из его результата без обращения к провайдеру.
import assert from 'node:assert/strict';
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
};
let calls = 0;
let executions = 0;
const model = new MockLanguageModelV3({
doGenerate: async () => {
calls += 1;
if (calls === 1) {
return {
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'add', input: '{"a":2,"b":3}' }],
finishReason: { unified: 'tool-calls', raw: undefined },
usage,
warnings: [],
};
}
return {
content: [{ type: 'text', text: 'The result is 5.' }],
finishReason: { unified: 'stop', raw: undefined },
usage,
warnings: [],
};
},
});
const add = tool({
description: 'Add two numbers',
inputSchema: z.object({ a: z.number(), b: z.number() }),
execute: async ({ a, b }) => {
executions += 1;
return a + b;
},
});
const result = await generateText({
model,
tools: { add },
stopWhen: stepCountIs(2),
prompt: 'Add 2 and 3.',
});
assert.equal(result.text, 'The result is 5.');
assert.equal(calls, 2);
assert.equal(executions, 1);
Добавьте тесты для отклонённых схем, чужого клиента, обхода пути, SSRF-адресов, повторного ключа идемпотентности, истёкшего подтверждения, отказа, списка MCP-инструментов и проверки вывода. Проверяйте в потоке части инструмента и подтверждения, а не только видимый текст. Запускайте adversarial cases для документов, которые велят модели игнорировать задачу или раскрыть контекст. В статье оценка AI-агентов описаны датасеты регрессии, проверки вызовов и измерение стоимости и задержки.
Миграция с AI SDK 5
Замените Experimental_Agent на ToolLoopAgent. Его настройка system переименована в instructions. Условие по умолчанию меняется с stepCountIs(1) на stepCountIs(20), поэтому после обновления модель может сделать больше вызовов. Укажите явный лимит. Замените generateObject и streamObject на generateText и streamText с Output.object, Output.array или другим output strategy. В потоковом результате используется partialOutputStream.
CoreMessage становится ModelMessage, а convertToModelMessages в AI SDK 6 асинхронна. ToolCallOptions переименован в ToolExecutionOptions. Mock-классы V2 заменяются MockLanguageModelV3 и другими V3 из ai/test. Опция провайдера structuredOutputs удалена у chat-моделей в пользу strictJsonSchema. При реализации toModelOutput деструктурируйте аргумент { output }, как требует сигнатура v6. Запустите codemod v6, затем вручную проверьте адаптеры провайдеров, преобразование сообщений, повтор подтверждения и потоковые тесты. Codemod переименует символы, но не решит, безопасно ли новое значение по умолчанию для вашего сценария.
Точная поверхность API зависит от закреплённого patch-релиза и провайдера. Перед обновлением прочитайте соответствующие версии справочника ToolLoopAgent, руководства по вызову инструментов, руководства по структурированным данным, руководства MCP, руководства по тестированию и руководства миграции с AI SDK 5 на 6. Считайте предупреждение провайдера ошибкой теста, если опция была проигнорирована.