Aller au contenu principal

Une intervention humaine encadrée par des contrats

· 16 minutes de lecture
Dave Rapin
Dave Rapin
Fondateur chez Curling IO
À propos de cet article

Cette note technique décrit l’architecture de l’assistant IA dans Curling IO v3. Elle s’adresse aux ingénieurs logiciels et aux autres personnes qui conçoivent des systèmes d’agents. Elle approfondit Rust, la persistance, l’autorisation et le traitement des échecs davantage que nos articles habituels sur le produit.

Le schéma habituel d’intervention humaine dans un agent IA ressemble à ceci : le modèle demande un appel d’outil, l’environnement d’exécution de l’agent s’arrête, une personne approuve l’appel, puis l’exécution reprend pour permettre à l’outil de s’exécuter.

C’est une conception généraliste raisonnable. Elle est aussi plus stricte que de laisser simplement un agent appeler tous les outils qu’il voit. Pour Curling IO, nous voulions exposer au modèle la plus petite surface possible et placer un garde-fou contrôlé par l’application sur chaque chemin menant à une écriture. Cela nous a amenés à poser une question plus stricte :

Si l’application possède déjà les détails exacts de l’appel, pourquoi redonner le contrôle à l’agent?

Au moment où nous demandons à un gestionnaire de club d’approuver une opération, Curling IO a analysé la requête du modèle, résolu chaque valeur par défaut, vérifié l’état actuel de l’application, produit un aperçu fixe et conservé les arguments exacts. Le modèle n’a plus rien d’utile à apporter à l’exécution. Nous ne le laissons donc pas exécuter l’opération et ne le relançons pas simplement pour donner suite à l’approbation.

L’agent propose. L’application transforme cette proposition en contrat. Une personne approuve le contrat. Rust l’exécute.

Le schéma d’approbation courant​

Le parcours d’intervention humaine du SDK OpenAI Agents peut suspendre une exécution lorsqu’un outil exige une approbation. L’application sérialise le RunState, enregistre les approbations ou les refus pour les appels d’outils en attente, puis reprend l’exécution initiale. L’outil approuvé s’exécute pendant cette reprise.

Les interruptions de LangGraph suivent une structure semblable. Un graphe peut s’arrêter avant un nœud d’outil, laisser une personne approuver, modifier ou refuser l’appel, puis reprendre vers l’outil ou revenir au modèle.

Ces cadres répondent à un problème général. Un agent peut avoir de nombreux outils, des agents imbriqués, du travail de longue durée et plusieurs points où une personne doit intervenir. Dans ce contexte, conserver l’appel d’outil en attente dans un état durable de l’agent est utile.

Le problème de Curling IO est plus restreint. Nous contrôlons l’application, la base de données, les règles d’autorisation, l’interface et chaque opération qu’un assistant peut proposer. Nous n’avons pas besoin qu’un environnement d’exécution générique d’agents conserve l’autorité après qu’une proposition est devenue un état de l’application.

Voici la différence :

Comparaison entre l’approbation habituelle d’un appel d’outil, où l’exécution de l’agent reprend après l’approbation humaine, et les contrats d’opération Curling IO, où Rust contrôle le contrat validé et l’exécute sans relancer le modèle.

Ne pas relancer le modèle après l’approbation a un sens précis. Pendant la préparation d’une proposition, les erreurs de validation qui peuvent être communiquées sans risque peuvent retourner dans la boucle active du modèle pour qu’il corrige sa requête. Si un gestionnaire refuse une proposition, ou si l’exécution constate un changement d’état récupérable, Curling IO enregistre un contexte de révision rédigé par l’application pour le prochain message du gestionnaire. Cela lance un nouveau tour du modèle avec un motif qui peut lui être communiqué sans risque. Un échec interne terminal est plutôt enregistré et signalé comme non réessayable. Le modèle peut s’adapter lorsque c’est utile, mais il ne contrôle jamais l’exécution du contrat approuvé.

Le premier schéma peut être mis en œuvre de façon sûre. Relancer un agent ne modifie pas automatiquement un appel approuvé. Un bon environnement d’exécution devrait conserver l’appel exact et son identité. Pour une application qui maîtrise ses propres opérations, l’exécution suspendue du modèle est un état opérationnel superflu dès que l’application a accepté une proposition complète.

L’approbation humaine ne suffit pas​

Un bouton d’approbation n’a de sens que si l’application peut dire exactement ce qui a été approuvé.

Supposons qu’un modèle demande de rembourser une commande. Une approbation peu précise pourrait afficher :

Rembourser ce client?

Presque toutes les décisions importantes restent alors cachées. Quel paiement? Quel montant? Quelle ligne de commande? Où ira l’argent? Le modèle utilise-t-il une valeur qu’il a calculée lui-même? Pourrait-il choisir une autre destination à la reprise de l’exécution?

Notre vérification de remboursement montre le participant, le produit, le rabais, le paiement, le montant, la destination et le nouveau total de la commande. Le gestionnaire n’approuve pas l’intention générale du modèle de corriger une commande. Il approuve une opération concrète et un ensemble précis d’effets.

Cela exige plus qu’un schéma d’outil. Il faut un contrat contrôlé par l’application qui définit ensemble :

  • les champs que le modèle doit fournir;
  • les valeurs par défaut que Rust peut résoudre;
  • les combinaisons valides et invalides;
  • la représentation typée conservée pour approbation;
  • la présentation fixe que la personne vérifie;
  • la revalidation selon l’état actuel;
  • l’exécuteur;
  • le résultat typé et sa restitution sûre;
  • les événements d’audit de tout le cycle de vie.

Si ces éléments sont répartis entre une consigne, un schéma JSON générique, une page de vérification construite à la main et un exécuteur sans lien avec eux, ils finiront par diverger. Un nouveau champ peut influer sur l’exécution sans apparaître dans la vérification. Une consigne peut décrire une valeur par défaut autrement que l’application. Un modèle peut produire une valeur que l’interface ordinaire n’autoriserait jamais.

Dans Curling IO, tous ces éléments font partie d’une même capacité.

La plus petite surface utile​

Notre premier choix est d’éviter de présenter une large surface à l’agent, avant même de chercher à la protéger.

Chaque assistant est limité à une section de Curling IO et ne reçoit que le contexte nécessaire à la tâche en cours. L’assistant des commandes ne reçoit pas une vue de la base de données de toute l’organisation. L’assistant des courriels n’hérite pas des outils de celui des commandes. L’identité de l’organisation, les permissions, les noms internes de champs, les charges utiles des fournisseurs et les dossiers de clients sans rapport avec la tâche restent du côté de l’application.

La même règle s’applique aux opérations. Un assistant voit un petit catalogue de propositions possibles, plutôt qu’une API d’écriture générique. Les champs présentés au modèle contiennent seulement les choix qui exigent réellement une interprétation. Rust fournit la portée des ressources, résout les valeurs par défaut, calcule les valeurs dérivées, refuse les combinaisons non prises en charge et construit l’aperçu destiné à la personne.

Nous ajoutons ensuite un garde-fou sur chaque voie restante entre la sortie du modèle et l’état durable :

  • un contexte limité et rédigé par l’application avant l’appel au modèle;
  • des outils de lecture propres à la tâche, dont le serveur contrôle la portée par organisation;
  • une analyse et une validation strictes de la requête d’opération du modèle;
  • des arguments et des aperçus typés construits par Rust;
  • l’approbation humaine de chaque effet important;
  • la vérification de l’autorisation, de l’expiration et de l’état au moment de l’exécution;
  • l’idempotence aux frontières de la proposition et de l’enregistrement métier;
  • des résultats typés, avec les renseignements sensibles masqués, après l’exécution.

Aucune vérification ne garantit à elle seule toute la sécurité. L’approbation humaine ne remplace pas l’autorisation. Un type ne prouve pas que l’état actuel permet encore l’opération. La revalidation n’empêche pas un appel en double au fournisseur après une réponse perdue. La surface reste petite, et chaque frontière conserve son rôle.

La requête du modèle n’est pas la proposition conservée​

Notre assistant des commandes a une opération présentée au modèle appelée propose_payment_refund. Son entrée est volontairement petite. Le modèle identifie le paiement, la ligne de commande, le rabais et la destination demandée à partir des éléments fournis par Curling IO.

Rust ne conserve pas directement cette requête. Il la valide à partir de l’examen de la commande contrôlé par le serveur, calcule le remboursement selon les règles de l’application, résout une destination concrète et crée des arguments typés :

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct OrderRefundArguments {
order_id: i64,
payment_id: i64,
line_item_id: i64,
discount_id: i64,
amount_cents: i64,
refund_destination: RefundDestination,
}

Le modèle ne choisit pas amount_cents. Il ne peut pas dire « utiliser le mode de paiement initial » et remettre cette décision à plus tard. Rust résout cette expression en une valeur concrète de RefundDestination avant que le gestionnaire voie quoi que ce soit.

La présentation à vérifier a un type distinct des arguments exécutables :

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct OrderRefundPreview {
participant_name: String,
product_name: String,
discount_name: String,
payment_method: PaymentMethod,
amount_cents: i64,
current_order_total_cents: i64,
resulting_order_total_cents: i64,
currency: String,
refund_destination: RefundDestination,
product_configuration_unchanged: bool,
}

Les arguments contiennent ce dont l’exécution a besoin. L’aperçu contient ce dont une personne a besoin pour comprendre les conséquences. Les deux proviennent des mêmes faits métier validés et sont figés ensemble.

JSON apparaît à la frontière de la base de données, mais ce n’est pas le modèle de programmation. Une opération est reconvertie dans son type Rust avant de pouvoir être revalidée ou exécutée. Un type d’opération ne peut pas être aiguillé vers le type d’arguments d’une autre opération.

Une proposition est un état durable de l’application​

Chaque proposition reçoit un identifiant public opaque et une ligne qui contient son organisation, l’administrateur demandeur, la section, la ressource, le type d’opération, les arguments exacts, l’aperçu, l’état et l’expiration. Elle conserve aussi les renseignements sur l’approbation, l’exécution, le résultat et les échecs.

Voici une vue simplifiée du côté Rust :

struct OperationProposal<Arguments, Preview, Result> {
public_id: ProposalId,
interaction_id: InteractionId,
scope: OperationScope,
requested_by: UserId,
operation_kind: OperationKind,
arguments: Arguments,
preview: Preview,
status: ProposalStatus,
expires_at: DateTime,
approval: Option<Approval>,
outcome: Option<OperationOutcome<Result>>,
}

struct OperationScope {
organization_id: OrganizationId,
section: AssistantSection,
resource_id: ResourceId,
}

Arguments, Preview et Result sont les types propres à un contrat d’opération. SQLite conserve leur forme sérialisée et les événements d’audit de la proposition, mais le code de l’application ne peut pas exécuter ces valeurs sans les décoder avec le contrat correspondant.

La conversation n’est pas la source de vérité de cette opération. L’état d’exécution conservé par le fournisseur de modèles ne l’est pas non plus. Une proposition en attente survit à une déconnexion du navigateur, à un changement de modèle ou à un déploiement, puisque Curling IO peut reconstruire l’approbation à partir de ses propres enregistrements.

Cela réduit aussi beaucoup l’entrée de la demande d’approbation. Le navigateur transmet l’identifiant opaque de la proposition. Il ne transmet pas une seconde fois le montant du remboursement, sa destination, le corps du message ou toute autre valeur approuvée.

L’approbation crée un seul contrat d’exécution​

Lorsque l’administrateur sélectionne Approuver le remboursement, Curling IO vérifie de nouveau toutes les conditions habituelles de la requête :

  • la personne connectée a toujours accès à l’organisation;
  • la proposition appartient à cette organisation, à cette section et à cette commande;
  • la proposition est encore en attente et n’a pas expiré;
  • son type d’opération et ses arguments conservés peuvent encore être analysés;
  • la personne actuelle est toujours autorisée à effectuer l’opération sous-jacente.

Seulement alors, la proposition passe de pending à executing. La mise à jour est atomique et produit un contrat d’exécution typé :

struct ExecutionContract<Arguments> {
proposal_id: ProposalId,
operation_kind: OperationKind,
scope: OperationScope,
arguments: Arguments,
approved_by: UserId,
approved_at: DateTime,
}

match proposals.approve_for_execution(proposal_id, administrator, now)? {
Approval::Claimed(contract) => execute(contract),
Approval::AlreadyCompleted(outcome) => present(outcome),
Approval::NotApprovable(reason) => reject(reason),
}

Le dépôt de données ne retourne Approval::Claimed que lorsqu’il fait passer de façon atomique la proposition correspondante, non expirée, de pending à executing. Si une autre requête l’a déjà approuvée, refusée ou terminée, le gestionnaire de requête ne reçoit aucun contrat d’exécution. Il ne reçoit donc pas une seconde autorisation de l’exécuter.

Le modèle ne participe à aucune de ces étapes. L’approbation est une requête authentifiée de l’administrateur à Curling IO, plutôt qu’un autre message dans la conversation.

La revalidation fait partie de l’exécution​

Figer une proposition empêche ses arguments de changer. Cela ne fige pas le reste du monde.

Un paiement a peut-être été remboursé dans un autre onglet. Quelqu’un a pu modifier le public d’une diffusion. L’administrateur a peut-être perdu son accès. Une proposition peut être restée ouverte assez longtemps pour expirer.

L’exécuteur de remboursement recharge donc la commande et prouve que les éléments initiaux restent valides. Il vérifie que :

  • la même ligne de commande et les mêmes éléments justifiant le rabais existent encore;
  • le montant calculé n’a pas changé;
  • le paiement sélectionné existe encore et sa valeur remboursable est suffisante;
  • aucune tentative de remboursement non résolue ne rend un nouvel appel risqué;
  • la destination concrète reste compatible avec le paiement et le compte.

Si un fait important a changé, l’ancienne proposition est bloquée par défaut. L’application enregistre un résultat qui exige une révision, explique le changement sans exposer de renseignements sensibles et demande au gestionnaire de préparer une nouvelle proposition. Elle ne modifie pas discrètement le montant sous une approbation qui en affichait un autre.

C’est le contrôle de concurrence optimiste exprimé en termes humains. L’aperçu affirme quelque chose sur un état précis. La revalidation prouve que cette affirmation est encore vraie lorsque l’approbation arrive.

L’idempotence doit aller jusqu’à l’enregistrement métier​

L’approbation conditionnelle empêche une même proposition en attente de démarrer deux fois, mais cela ne suffit pas pour les opérations qui ont des effets externes.

Prenons un remboursement de carte en ligne. Curling IO peut envoyer la requête au fournisseur et perdre la réponse HTTP. À ce stade, réessayer peut créer un second remboursement. Le résultat correct n’est pas un échec générique. C’est OperationOutcome::NeedsReconciliation { status_path }.

Le parcours de remboursement conserve des identités stables pour la proposition et le fournisseur, enregistre la tentative non résolue et refuse d’improviser un autre appel. La récupération vérifie la tentative initiale et finit par enregistrer le résultat faisant autorité.

Pour les opérations locales de base de données, la modification métier et le résultat réussi de la proposition sont validés dans la même transaction. Pour les opérations externes, la proposition reste dans un état d’exécution ou de rapprochement jusqu’à ce que la récupération lève l’incertitude.

Approuver de nouveau une proposition terminée retourne son résultat initial conservé. Cela ne fabrique pas un nouveau message de réussite et n’exécute pas l’opération une seconde fois. La répétition concerne la livraison du résultat, pas un nouveau résultat métier.

Cette distinction compte, puisque les réponses HTTP ne sont pas durables. L’enregistrement de l’opération l’est.

Restituer le résultat sans relancer l’agent​

L’application sait déjà ce qui s’est passé. Elle ne devrait donc pas dépenser un autre appel au modèle pour lui demander de le reformuler.

Nous utilisons un petit vocabulaire de résultats typés autour du résultat propre à chaque opération :

enum OperationOutcome<S> {
Succeeded(S),
NeedsReconciliation {
status_path: String,
},
RevisionRequired {
reason: RevisionReason,
},
TerminalFailure {
correlation_id: String,
},
}

Le S propre à l’opération peut identifier le remboursement et le solde de commande qui en résulte, ou indiquer qu’un brouillon de courriel a été copié dans le formulaire modifiable. L’énumération commune indique ce que la personne et le système peuvent faire ensuite sans risque.

Un résultat conservé alimente plusieurs présentations :

  • une réponse localisée dans le navigateur;
  • un suivi rédigé par l’application dans la conversation de l’assistant;
  • un contexte limité fourni si le gestionnaire envoie un autre message;
  • éventuellement, le même résultat sémantique par une API destinée aux agents logiciels.

Le modèle ne classe pas l’échec et ne décide pas si une nouvelle tentative est sûre. Rust s’en charge. Au prochain tour de la personne, un résultat récupérable donne au modèle assez de contexte sûr pour examiner de nouveau la situation ou préparer une proposition révisée.

Pour une violation d’invariant interne ou un contrat conservé corrompu, l’application enregistre elle-même l’erreur opérationnelle. Le modèle et le navigateur reçoivent un message terminal avec un identifiant de corrélation, sans trace de pile ni invitation à continuer d’essayer. Demander à l’agent de soumettre un billet de soutien ajouterait une étape sujette aux échecs tout en écartant le contexte que l’application possède déjà.

Deux assistants ont suffi à faire ressortir la frontière​

Nous avons actuellement deux contrats d’opération, volontairement différents.

L’assistant des commandes peut proposer order.refund_missing_discount. L’approbation peut mener à une opération financière avec un fournisseur externe, des réponses incertaines et du travail de rapprochement.

L’assistant des diffusions de courriel peut proposer email_broadcast.apply_draft. L’approbation copie les filtres, l’objet et le message figés dans un formulaire modifiable. Elle ne crée pas la diffusion et ne l’envoie pas. C’est une opération locale, mais elle recompte quand même les destinataires avant d’appliquer le brouillon. Si le public a changé, le gestionnaire doit obtenir une nouvelle proposition.

Cette différence nous a empêchés de construire un cadre de remboursement et de le présenter comme un cadre d’agents. La partie commune est petite : cycle de vie, identité d’approbation, sémantique des résultats, audit et restitution. Les types d’arguments, les aperçus, la revalidation et l’exécution restent dans l’opération qui les comprend.

Nous prévoyons ajouter des assistants à quelques dizaines de sections. Chaque nouvelle opération nécessitera une requête typée, des arguments figés, un aperçu destiné à une personne, un revalidateur, un exécuteur, un résultat typé et des tests ciblés pour l’expiration, l’état périmé, la répétition et la classification des échecs.

C’est plus de travail que d’ajouter un autre outil de fonction à une consigne. C’est voulu.

Ce que ce schéma apporte et ses limites​

Nous n’avons inventé ni l’approbation humaine, ni les commandes durables, ni le contrôle de concurrence optimiste, ni la sécurité par capacités, ni les clés d’idempotence. La conception emprunte à tous ces concepts. Les cadres d’agents permettent déjà de suspendre les appels d’outils pour vérification.

Le changement utile consiste à traiter la modification demandée par l’agent comme une entrée d’une commande applicative, plutôt que comme la commande elle-même. L’application matérialise un nouvel objet durable dont la sémantique est plus stricte que celle de l’appel au modèle à son origine. Dès que cet objet existe, on peut abandonner l’exécution du modèle.

Ce schéma n’est pas nécessaire pour chaque réponse d’assistant. Les réponses en lecture seule n’ont pas besoin de propositions figées. Un brouillon sans effet sur l’application peut rester un brouillon. Mais si une opération change les données d’un client, envoie quelque chose, déplace de l’argent ou modifie un accès, nous voulons une garantie plus forte que « le modèle a appelé un outil et une personne a cliqué sur Approuver ».

Nous voulons savoir exactement ce que l’application s’est engagée à faire, exactement ce que la personne a approuvé, exactement quels faits actuels ont été revérifiés et exactement ce qui s’est passé ensuite.

C’est le contrat.