Result : une frontière contre les réponses du modèle
Durée indicative : environ 30 min.
Le problème que ce module résout
Au module 5, une ligne lisait l'argument du modèle avec un cast : (demande.Arguments["orderId"] :?> JsonElement).GetInt32(). Elle a fonctionné parce que le modèle a bien envoyé {"orderId":4512}. Mais le modèle est une source de données que vous ne contrôlez pas : il peut demander un outil qui n'existe pas, oublier l'argument, envoyer "ABC-12" au lieu d'un nombre, ou produire un JSON tronqué.
Ce module construit une seule fonction frontière qui transforme le nom d'outil et le texte JSON des arguments en une valeur typée, ou en une erreur qui porte un nom. Pas de modèle ici : vous travaillez sur des échantillons fixes, pour que chaque cas soit reproductible.
Pourquoi ne pas désérialiser directement ?
Le réflexe C# serait JsonSerializer.Deserialize<ArgumentsCommande>(json). Créez naif.fsx pour voir ce que cela donne :
// naif.fsx — module 06 : désérialiser directement les arguments du modèle.
open System.Text.Json
type ArgumentsCommande = { orderId: int }
let essayer (argsJson: string) =
try
let valeur = JsonSerializer.Deserialize<ArgumentsCommande> argsJson
printfn "%-20s -> %A" argsJson valeur
with ex ->
printfn "%-20s -> exception %s" argsJson (ex.GetType().Name)
essayer """{"orderId":4512}"""
essayer """{}"""
essayer """{"orderId":"ABC-12"}"""
essayer """{"orderId":45"""
"""...""" est une chaîne F# à triple guillemet : les guillemets intérieurs n'ont pas besoin d'être échappés, et try ... with est le try/catch de C#. Lancez dotnet fsi naif.fsx. Sortie obtenue avec .NET 10 :
{"orderId":4512} -> { orderId = 4512 }
{} -> { orderId = 0 }
{"orderId":"ABC-12"} -> exception JsonException
{"orderId":45 -> exception JsonException
La deuxième ligne est la plus dangereuse : un argument manquant ne provoque aucune erreur, il devient 0. Votre agent chercherait la commande 0 et répondrait au client qu'elle est introuvable. Les deux autres cas lèvent la même exception, sans dire lequel des problèmes s'est produit.
Une autre piste serait de décrire la demande d'outil elle-même par une union discriminée et de la désérialiser. D'après Microsoft Learn, System.Text.Json sait sérialiser et désérialiser les unions discriminées F# à partir de .NET 11 ; ce cours cible .NET 10 (LTS), où une union provoque une NotSupportedException. La méthode de ce module, une lecture manuelle avec JsonDocument, fonctionne quelle que soit la version.
Result : une union à deux cas, encore
En C#, une opération qui peut échouer pour une raison précise s'écrit soit avec une exception, soit avec le motif bool TryParse(string s, out int valeur) — qui dit si cela a échoué, mais pas pourquoi. F# a un type pour cela, et vous savez déjà le lire :
type Result<'T, 'E> =
| Ok of 'T
| Error of 'E
Un Result<ToolCall, ToolError> est soit Ok avec une demande d'outil valide, soit Error avec une erreur. Et l'erreur sera elle-même une union discriminée, pour que chaque défaut ait son nom.
Différence avec Option : None dit « rien », Error dit « rien, et voici pourquoi ».
Les types de la frontière
Créez decodage.fsx avec les deux types et les deux premières étapes, fournies :
// decodage.fsx — module 06 : la frontière entre les données du modèle et votre code.
// Aucun package : System.Text.Json fait partie de .NET.
// Ce fichier n'affiche rien : il est chargé par echantillons.fsx et agent.fsx.
open System.Text.Json
// Ce que votre code accepte d'exécuter : la liste fermée des outils autorisés.
type ToolCall =
| GetOrder of orderId: int
// Tout ce qui peut être faux dans une demande d'outil, avec un nom.
type ToolError =
| UnknownTool of name: string
| InvalidJson of raw: string
| MissingArgument of name: string
| BadArgumentType of name: string * received: string
// Étape 1 — le nom d'outil fait-il partie de la liste autorisée ?
let verifierNomOutil (nom: string) : Result<string, ToolError> =
if nom = "getOrder" then Ok nom else Error(UnknownTool nom)
// Étape 2 — le texte des arguments est-il un objet JSON ?
let lireJson (argsJson: string) : Result<JsonElement, ToolError> =
try
use document = JsonDocument.Parse argsJson
if document.RootElement.ValueKind = JsonValueKind.Object then
Ok(document.RootElement.Clone())
else
Error(InvalidJson argsJson)
with :? JsonException ->
Error(InvalidJson argsJson)
ToolCall n'a qu'un cas pour l'instant : c'est la liste de ce que votre code accepte d'exécuter. Vous verrez au module 8 que ce type fermé est votre premier garde-fou.
use est l'équivalent du using de C# : le document JSON est libéré à la fin de la fonction. lireJson contient aussi un try ... with, et ce n'est pas une contradiction : JsonDocument.Parse est une API .NET qui signale un JSON invalide par une exception. On l'attrape ici, une seule fois, pour une seule raison, et on la transforme aussitôt en un cas nommé. Au-delà de cette ligne, plus aucune exception ne circule.
Enchaîner des étapes qui peuvent échouer
Il reste une troisième étape — lire orderId — puis à enchaîner les trois. Avec des match imbriqués, cela donnerait un escalier : si l'étape 1 est Ok, alors faire l'étape 2 ; si l'étape 2 est Ok, alors… F# fournit deux fonctions qui font ce travail pour vous :
Result.bind etape: si la valeur reçue estOk x, appelleetape x(qui renvoie elle-même unResult) ; si c'estError e, renvoieError esans appeleretape;Result.map f: si la valeur estOk x, renvoieOk (f x); sinon, laisse passer l'erreur. On l'utilise pour une transformation qui ne peut pas échouer.
Avec le pipe |> du premier palier, les étapes se lisent de haut en bas, et la première erreur court-circuite tout le reste. C'est ce qu'on appelle la programmation orientée rail : deux rails, Ok et Error, et chaque étape peut seulement faire passer la valeur du premier au second.
Exercice — compléter la frontière
Ajoutez à decodage.fsx les deux fonctions suivantes, en complétant les corps :
// Étape 3 — l'argument orderId est-il présent, et est-ce un entier ?
let lireOrderId (racine: JsonElement) : Result<int, ToolError> =
// À vous : MissingArgument si absent, BadArgumentType si pas un entier, sinon Ok
// Le pipeline : la première erreur court-circuite les étapes suivantes.
let decoder (nom: string) (argsJson: string) : Result<ToolCall, ToolError> =
// À vous : verifierNomOutil, lireJson, lireOrderId, puis construire GetOrder
Indices, à n'utiliser que si vous bloquez :
racine.TryGetProperty "orderId"renvoie un tuple(bool * JsonElement): F# transforme le paramètreoutde .NET en second élément du tuple ; faites unmatchsurfalse, _ettrue, valeur.- Une valeur est un entier si
valeur.ValueKind = JsonValueKind.Numberetfst (valeur.TryGetInt32())est vrai (fstrenvoie le premier élément d'une paire). Ne testezTryGetInt32qu'après avoir vérifié que c'est un nombre : sur une chaîne, il lève une exception. Pour le texte reçu,valeur.ToString()suffit. - Dans
decoder,verifierNomOutilrenvoie le nom, dont l'étape suivante n'a pas besoin :Result.bind (fun _ -> lireJson argsJson). GetOrderest un cas d'union, donc une fonctionint -> ToolCall:Result.map GetOrdertermine le pipeline.
Solution
let lireOrderId (racine: JsonElement) : Result<int, ToolError> =
match racine.TryGetProperty "orderId" with
| false, _ -> Error(MissingArgument "orderId")
| true, valeur ->
let estUnEntier =
valeur.ValueKind = JsonValueKind.Number && fst (valeur.TryGetInt32())
if estUnEntier then
Ok(valeur.GetInt32())
else
Error(BadArgumentType("orderId", valeur.ToString()))
let decoder (nom: string) (argsJson: string) : Result<ToolCall, ToolError> =
verifierNomOutil nom
|> Result.bind (fun _ -> lireJson argsJson)
|> Result.bind lireOrderId
|> Result.map GetOrder
Si vous avez écrit decoder avec des match imbriqués, c'est un bon premier jet : il est correct. Réécrivez-le maintenant avec Result.bind — c'est la forme visée, et celle que l'agent utilisera.
Exercice — prédire puis vérifier : cinq échantillons
Créez echantillons.fsx. Chaque échantillon est une demande d'outil brute, telle qu'un modèle pourrait l'envoyer :
// echantillons.fsx — module 06 : demandes d'outil brutes, comme en enverrait un modèle.
#load "decodage.fsx"
open Decodage
let echantillons =
[
("getOrder", """{"orderId":4512}""")
("cancelOrder", """{"orderId":7781}""")
("getOrder", """{}""")
("getOrder", """{"orderId":"ABC-12"}""")
("getOrder", """{"orderId":45""")
]
for (nom, argsJson) in echantillons do
printfn "%-11s %-20s -> %A" nom argsJson (decoder nom argsJson)
Avant de lancer, écrivez pour chacune des cinq lignes le résultat que vous attendez : Ok (...) ou Error (...) avec le cas exact. Puis lancez dotnet fsi echantillons.fsx et comparez avec la section suivante.
Sortie attendue
getOrder {"orderId":4512} -> Ok (GetOrder 4512)
cancelOrder {"orderId":7781} -> Error (UnknownTool "cancelOrder")
getOrder {} -> Error (MissingArgument "orderId")
getOrder {"orderId":"ABC-12"} -> Error (BadArgumentType ("orderId", "ABC-12"))
getOrder {"orderId":45 -> Error (InvalidJson "{"orderId":45")
Cinq entrées, cinq résultats distincts, aucune exception. Notez la deuxième ligne : {"orderId":7781} est un JSON parfaitement valide, mais l'erreur est UnknownTool, parce que l'étape du nom vient en premier et court-circuite le reste. (%A n'échappe pas les guillemets intérieurs, d'où l'aspect de la dernière ligne.)
Exercice — voir le court-circuit
Ajoutez temporairement cette ligne au tout début du corps de lireOrderId :
printfn " (lireOrderId exécutée)"
Combien de fois cette ligne va-t-elle s'afficher pour les cinq échantillons, et avant lesquels ? Notez votre réponse, relancez echantillons.fsx, puis comparez.
Ce que vous devez observer
(lireOrderId exécutée)
getOrder {"orderId":4512} -> Ok (GetOrder 4512)
cancelOrder {"orderId":7781} -> Error (UnknownTool "cancelOrder")
(lireOrderId exécutée)
getOrder {} -> Error (MissingArgument "orderId")
(lireOrderId exécutée)
getOrder {"orderId":"ABC-12"} -> Error (BadArgumentType ("orderId", "ABC-12"))
getOrder {"orderId":45 -> Error (InvalidJson "{"orderId":45")
Trois fois. Pour cancelOrder, l'erreur de l'étape 1 a empêché les étapes 2 et 3 ; pour le JSON tronqué, l'erreur de l'étape 2 a empêché l'étape 3. Result.bind n'a même pas appelé la fonction. Retirez la ligne de trace avant de continuer : decodage.fsx ne doit rien afficher, car l'agent le chargera.
Pourquoi pas un try ... with autour de tout ?
On pourrait entourer le cast du module 5 d'un try ... with _ -> "erreur". Le programme ne planterait plus, mais les cinq situations ci-dessus deviendraient une seule erreur muette, et l'argument manquant — qui ne lève pas d'exception — passerait toujours comme 0. Avec la frontière, chaque défaut a un nom que le reste du code peut traiter différemment : au module suivant, ce nom sera renvoyé au modèle pour qu'il corrige sa demande.
Ce que vous venez de faire
Vous avez vu qu'une désérialisation directe transforme un argument manquant en 0 sans prévenir, représenté chaque défaut possible d'une demande d'outil par un cas d'union, et écrit une frontière decoder : string -> string -> Result<ToolCall, ToolError> en enchaînant trois étapes avec Result.bind et Result.map. Vous avez vérifié que la première erreur empêche l'exécution des étapes suivantes, et que cinq entrées différentes donnent cinq résultats distincts sans aucune exception.
Au module suivant, tout se rejoint : le modèle, l'outil, la frontière — et une troisième union discriminée qui décrit ce que le modèle a produit à chaque tour d'une boucle d'agent.
Vérifiez votre compréhension
Avec `decoder nom argsJson = verifierNomOutil nom |> Result.bind (fun _ -> lireJson argsJson) |> Result.bind lireOrderId |> Result.map GetOrder`, que renvoie `decoder "getorder" """{"orderId":12"""` (nom en minuscules et JSON tronqué) ?
Le modèle demande `getOrder` avec les arguments `{"orderId":12.5}`. Que renvoie votre fonction `decoder` ?
Pour aller vite, un collègue remplace `decoder` par `try Ok (GetOrder (JsonSerializer.Deserialize<ArgumentsCommande>(argsJson).orderId)) with _ -> Error "erreur"`. Qu'est-ce qui ne va pas ?
Envie d'être prévenu des prochains modules ?
L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.