AI SDK 6 établit pour une application TypeScript une frontière nette entre les décisions du modèle et l’autorité de l’application. Le modèle peut choisir un outil typé, recevoir son résultat et poursuivre la conversation. Votre code continue de valider les entrées, d’appliquer les permissions, de décider si un effet de bord exige une personne et d’enregistrer ce qui s’est passé. Cette séparation est le principe de conception utile d’un agent en production. Cet article utilise les API stables d’AI SDK 6, et non l’API d’approbation plus récente d’AI SDK 7. Installez ai@6 avec un paquet fournisseur compatible avec la branche 6.x, par exemple @ai-sdk/openai@3, puis verrouillez les versions dans le lockfile.
La boucle que vous construisez
Un appel unique au modèle peut renvoyer du texte ou un appel d’outil. Une boucle d’outils ajoute un nouvel appel au modèle après l’exécution de l’outil. Le modèle peut ainsi interpréter le résultat et décider si un autre outil est nécessaire. Chaque génération est une étape. La boucle s’arrête lorsque le modèle ne demande plus d’outil, lorsqu’un outil appelé n’a pas de fonction execute, lorsqu’une approbation est nécessaire ou lorsqu’une condition stopWhen est satisfaite. Le résultat expose le texte final, les appels et résultats d’outils, les messages de réponse, l’utilisation et steps. La boucle reste donc observable et testable.
ToolLoopAgent encapsule ce comportement dans un objet réutilisable. Son constructeur demande un LanguageModel et accepte instructions, tools, stopWhen, output, prepareStep, maxRetries, les délais et les callbacks. Dans AI SDK 6, la condition par défaut est stepCountIs(20). Définissez une limite plus petite pour un workflow borné. Cette limite contrôle le coût et la disponibilité, mais ne remplace pas l’autorisation.
Un ToolLoopAgent typé
L’aide tool déduit le type des arguments de execute à partir de inputSchema. Le schéma est envoyé au fournisseur et sert à valider les arguments fournis par le modèle. Le modèle ne devient toutefois pas fiable par magie. Une forme valide peut contenir le mauvais compte, un chemin interdit, une URL dangereuse ou un montant non autorisé.
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);
Un outil doit réaliser une seule opération et renvoyer un résultat sérialisable de petite taille. La description doit préciser les unités, les permissions, la fraîcheur des données et le comportement en cas d’erreur. Utilisez strict: true lorsque le fournisseur prend en charge les appels stricts et que le schéma est compatible. Le mode strict améliore la fiabilité, mais ne fait pas office d’autorisation. toolChoice: 'auto' laisse le modèle décider, required impose un outil disponible, none désactive les outils et { type: 'tool', toolName: 'getWeather' } choisit un outil nommé. activeTools peut réduire les outils exposés à une étape.
Appels explicites et limites
Utilisez generateText pour un appel ponctuel ou lorsque vous devez contrôler directement l’historique. Ajoutez stopWhen si les résultats d’outils doivent être renvoyés au modèle. Sans cette option, generateText ne fait qu’une génération et renvoie l’appel d’outil sans poursuivre automatiquement.
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));
Les conditions intégrées sont stepCountIs(count), hasToolCall(toolName) et isLoopFinished(). Un tableau arrête la boucle dès qu’une condition correspond. isLoopFinished() n’a pas de maximum. Utilisez-le seulement avec un budget externe, un délai, l’annulation et un quota fournisseur. Une StopCondition personnalisée reçoit { steps } et peut s’arrêter sur un état métier ou un budget de tokens. Les conditions sont évaluées lorsque la dernière étape contient des résultats d’outils. Si vous combinez appel d’outils et sortie structurée, réservez une étape supplémentaire pour la sortie.
maxRetries réessaie les appels de modèle qui échouent. Il ne rend pas execute idempotent. Un outil qui envoie un courriel, débite une carte ou crée une ligne doit utiliser une clé d’idempotence et détecter lui-même les doublons. Pour un client MCP distant, les reprises sont désactivées par défaut et se configurent avec maxRetries dans createMCPClient. Ne réessayez que les erreurs réseau ou de limitation. Une requête tools/call non idempotente ne doit pas être rejouée aveuglément.
Une sortie structurée exploitable en TypeScript
AI SDK 6 déprécie generateObject et streamObject au profit de generateText et streamText avec output. Output.object accepte un schéma Zod, Valibot ou JSON Schema. La réponse complète est analysée et validée avant que result.output soit résolu. Les objets partiels conviennent à une interface, mais ils ne constituent pas une validation finale.
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);
La validation comporte deux niveaux. Le fournisseur peut imposer un format, puis AI SDK analyse le JSON reçu et le vérifie avec le schéma. L’application doit encore contrôler les règles métier, par exemple l’appartenance d’un identifiant au tenant courant ou le droit d’un score à déclencher un remboursement. Gardez des schémas fermés et précis. Limitez les chaînes, tableaux et nombres, puis utilisez des valeurs énumérées. Refusez les commandes inconnues au lieu de transmettre un JSON arbitraire à un adaptateur privilégié.
Avec des outils, le modèle peut d’abord effectuer une recherche puis produire le rapport. Définissez stopWhen: stepCountIs(4) ou un autre budget explicite, car l’étape de sortie structurée appartient au même flux multipas. En cas d’échec d’analyse, interceptez l’erreur du SDK, conservez l’identifiant de corrélation et les métadonnées du fournisseur, puis renvoyez une réponse sûre qui peut être réessayée. Ne demandez pas à un second modèle d’autoriser une sortie malformée.
Streaming du texte et des événements
streamText fournit des flux asynchrones pour le texte et la sortie structurée. ToolLoopAgent.stream renvoie un StreamTextResult après préparation de l’appel. Attendez donc le résultat avant de lire textStream. Le flux texte contient le texte généré, tandis que le résultat complet et les callbacks exposent les appels et résultats d’outils.
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);
}
Pour une sortie structurée en streaming, utilisez streamText avec Output.object et consommez partialOutputStream. onStepFinish permet de conserver une étape terminée, l’usage et un événement d’audit. Le flux peut se terminer par tool-approval-request, tool-error ou tool-output-denied, et pas seulement par du texte. Un client qui n’affiche que le texte peut cacher une action en attente d’une personne.
Approbation humaine des effets de bord
Dans AI SDK 6, définissez needsApproval sur un outil, à true ou sur un prédicat asynchrone fondé sur une entrée validée. Le premier appel à generateText ou streamText renvoie une partie tool-approval-request. Le serveur ne se met pas en pause dans l’attente d’un navigateur. Enregistrez les messages de réponse, affichez le nom exact de l’outil et ses arguments, ajoutez un tool-approval-response dans un nouveau message tool, puis rappelez le modèle.
Cet exemple Node complet utilise une question dans le terminal comme frontière humaine. Une application web conserverait messages, approvalId, toolCallId, l’identité du vérificateur et l’expiration dans un stockage serveur.
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);
}
L’approbation ne remplace pas l’autorisation. Juste avant l’effet de bord, vérifiez à nouveau l’utilisateur, le tenant, la cible, la politique et la version de la ressource. Reliez l’approbation stockée à approvalId, toolCallId, au nom de l’outil et au hash de l’entrée validée. Donnez-lui une expiration, une seule utilisation et refusez toute entrée modifiée. Un refus doit devenir un résultat d’outil clair et ne pas être réessayé automatiquement. Pour une action sensible, affichez la cible, l’identité, les arguments, les données qui sortent du système et l’effet attendu, pas seulement le résumé du modèle.
Outils dangereux et frontières de sécurité
N’exposez pas au modèle un shell général, un client HTTP sans restriction, un chemin de fichier arbitraire ou une connexion directe à la base. Préférez des opérations étroites comme deleteDraft, createCalendarEvent ou lookupOrder. Placez l’autorisation et les listes d’autorisation dans execute ou dans un service de politiques. Vérifiez le schéma de l’URL, l’hôte, le port, l’adresse résolue et les redirections. Résolvez les chemins dans une racine autorisée en tenant compte des liens symboliques. Vérifiez la propriété du tenant dans la requête de base, et pas seulement dans le prompt.
Considérez les descriptions d’outils, les documents récupérés, la mémoire et les résultats d’outils comme non fiables. Une injection de prompt peut demander au modèle de révéler un secret, d’appeler un outil sans rapport ou d’envoyer des données vers le site d’un attaquant. Le modèle n’est pas une frontière de sécurité. Les principes de moindre privilège, provenance, sortie réseau, isolation, limites et approbations sont détaillés dans Injection de prompt et sécurité MCP. Pour séparer planificateur, exécuteur, politique et stockage, consultez l’architecture d’un agent IA en production.
Ne placez jamais de clés API dans les prompts ou les résultats. Masquez les jetons dans les logs et la télémétrie. N’enregistrez pas l’entrée complète si elle peut contenir des données personnelles. Faites circuler un identifiant de requête à travers les appels modèle et les outils. Limitez la taille d’un résultat avant de l’ajouter au prompt suivant. Utilisez des identifiants séparés pour la lecture, le brouillon et la validation. Exécutez un agent navigateur ou fichier dans un worker isolé, sans secrets inutiles et avec un réseau refusé par défaut.
MCP sans perdre le typage
Le paquet @ai-sdk/mcp adapte les outils d’un serveur MCP en outils AI SDK. AI SDK 6 recommande HTTP en production et réserve stdio aux serveurs locaux. Définissez explicitement les schémas lorsqu’un serveur est externe à votre contrôle ou qu’un outil est sensible. Vous gardez ainsi une liste étroite et des types d’entrée utiles. Utilisez redirect: 'error' si les redirections ne sont pas prévues, validez les origines des serveurs d’autorisation OAuth et fermez le client dans finally ou 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();
}
La découverte automatique par mcpClient.tools() est pratique, mais elle expose chaque outil annoncé et ne fournit pas de types d’entrée au moment de la compilation. Les schémas explicites ne chargent que les outils nommés. Ne réessayez que les erreurs réseau transitoires. Les erreurs d’application MCP et les réponses réussies portant isError: true doivent être transmises sans rejouer un effet de bord. OAuth exige toujours la vérification de l’audience, PKCE, des jetons courts, des URI de redirection exactes et une liste d’autorisation des serveurs découverts. La partie serveur est présentée dans Serveur MCP TypeScript avec OAuth.
Erreurs, tests et exploitation
Entourez la génération d’une frontière qui distingue entrée invalide, échec fournisseur, délai dépassé, annulation, sortie malformée et erreur d’outil. AI SDK transforme une exception de execute en partie tool-error, afin que le modèle multipas puisse voir l’échec. Renvoyez un message nettoyé depuis l’outil ou transformez le résultat avant de le donner au modèle. Ne révélez ni stack trace, ni secret, ni SQL, ni chemin local, ni corps de réponse upstream. Bornez chaque requête avec abortSignal et timeout. Enregistrez finishReason, usage, totalUsage, le nombre d’étapes et la décision de politique.
AI SDK 6 fournit des mocks déterministes dans ai/test. MockLanguageModelV3 permet de renvoyer un appel d’outil à la première génération et du texte à la seconde. Le test suivant prouve que la boucle exécute l’outil une seule fois et obtient la réponse finale sans contacter de fournisseur.
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);
Ajoutez des tests pour les schémas refusés, les mauvais tenants, le traversal de chemin, les adresses SSRF, les clés d’idempotence répétées, l’expiration d’approbation, le refus, la liste d’outils MCP et la validation de sortie. Testez les parties outil et approbation du flux, pas uniquement le texte visible. Lancez des cas adversariaux avec des documents qui ordonnent au modèle d’ignorer sa tâche ou de divulguer son contexte. Le guide évaluation des agents IA décrit les jeux de régression, les assertions d’appels et la mesure du coût et de la latence.
Notes de migration depuis AI SDK 5
Remplacez Experimental_Agent par ToolLoopAgent. Son réglage system devient instructions. La condition par défaut passe de stepCountIs(1) à stepCountIs(20), donc une mise à niveau peut provoquer davantage d’appels. Définissez une limite explicite. Remplacez generateObject et streamObject par generateText et streamText avec Output.object, Output.array ou une autre stratégie. Le résultat en streaming utilise partialOutputStream.
CoreMessage devient ModelMessage, et convertToModelMessages est asynchrone dans AI SDK 6. ToolCallOptions devient ToolExecutionOptions. Les mocks V2 deviennent MockLanguageModelV3 et les autres mocks V3 de ai/test. L’option fournisseur structuredOutputs est supprimée des modèles de chat au profit de strictJsonSchema. Pour toModelOutput, destructurez l’argument { output } conformément à la signature v6. Lancez le codemod v6, puis contrôlez à la main les adaptateurs, les conversions de messages, la reprise d’approbation et les tests de streaming. Un codemod renomme les symboles, mais ne sait pas si une nouvelle valeur par défaut convient à votre workflow.
La surface exacte dépend du patch verrouillé et du fournisseur. Avant une mise à niveau, consultez la référence ToolLoopAgent, le guide des appels d’outils, le guide des données structurées, le guide MCP, le guide de test et le guide de migration AI SDK 5 vers 6. Traitez comme une erreur de test tout avertissement indiquant qu’une option a été ignorée.