Le premier appel d’outil est le plus simple. Le plus difficile, c’est la tentative suivante, après que le CRM a accepté une modification, que le réseau a perdu la réponse et que le worker a redémarré. À ce stade, ni une réponse fluide ni un prompt système plus long ne vous disent si l’action métier a eu lieu. Vous avez un problème de systèmes distribués, avec un planificateur probabiliste greffé dessus.
Prenons un agent qui prépare une note pour une fiche client. Une note en double peut sembler anodine, mais le même schéma d’exécution finit par atteindre la création de commandes, une modification de compte ou une opération financière. Le runtime ne devrait pas découvrir sa sémantique de livraison au moment où l’action devient coûteuse.
Cette architecture de référence sépare cinq responsabilités : l’entrée des requêtes authentifiées, la récupération des preuves, la planification par le modèle, l’admission des commandes et l’exécution des effets de bord. Seul le dernier composant détient une capacité de connecteur à portée restreinte. Le planificateur ne reçoit jamais d’identifiant à usage général, et ni un document récupéré ni le résultat d’un outil ne peuvent lui accorder une nouvelle permission.
1. Rendre la frontière d’autorité exécutable
Le modèle peut proposer un nom d’action et des arguments. Tout le reste provient de services de confiance. Résolvez le tenant et le principal à partir d’une session vérifiée ; validez l’action par rapport à une allowlist versionnée ; validez les arguments par rapport à un schéma fermé. Rejetez les propriétés inconnues et les entrées trop longues. Résolvez la ressource cible au sein du même tenant avant d’admettre une commande.
C’est important, car un appel d’outil syntaxiquement valide n’est pas pour autant autorisé. L’OWASP identifie l’injection de prompt indirecte via des contenus externes comme un risque. Un document qui demande à un assistant d’exporter des enregistrements reste un document, pas une autorité. Délimiteurs, ancrage par la récupération et texte d’instructions sont des couches utiles, mais aucune ne remplace le contrôle appliqué à la frontière du connecteur.
Le contrat suivant intervient après la validation et l’approbation. Son type relève de la documentation, pas d’un mécanisme de sécurité : les types TypeScript disparaissent à l’exécution. Seul le service d’admission doit construire et persister cet enregistrement, et le dispatcher doit le charger depuis un stockage de confiance plutôt que d’accepter un objet fourni par le modèle.
type ApprovedCommand = Readonly<{
tenantId: string;
operationId: string;
principalId: string;
action: "crm.note.create";
resourceId: string;
payload: Readonly<{ body: string }>;
requestHash: string;
expectedRevision: string;
authorizationDecisionId: string;
policyVersion: string;
expiresAt: string;
}>;
type ExecutionOutcome =
| { state: "succeeded"; receipt: string }
| { state: "denied" | "expired"; reason: string }
| { state: "unknown"; operationId: string };
2. Approuver une commande, pas une conversation
Une approbation doit identifier précisément le tenant, le principal, l’action, la cible, les arguments et la révision attendue de la ressource. Calculez côté serveur un condensé sur une représentation canonique de ces champs validés. Conservez à ses côtés la décision d’approbation, la version de la politique et la date d’expiration. Ne hachez pas une chaîne JSON arbitraire en supposant que des ordres de propriétés différents correspondent à des requêtes différentes.
L’interface d’approbation doit montrer la modification concrète proposée et les éléments nécessaires pour en juger. Après l’approbation, figez cette commande. Si le modèle révise le contenu, si l’enregistrement cible change ou si la précondition métier expire, créez une nouvelle proposition et obtenez à nouveau la décision appropriée. Ne réutilisez pas discrètement l’ancienne approbation pour un nouveau payload.
Au moment de l’envoi, vérifiez que la décision est toujours valide et contrôlez à nouveau la politique d’accès en vigueur. Une permission révoquée doit arrêter une action en file d’attente. Lorsque le système métier prend en charge l’écriture conditionnelle, envoyez la révision attendue comme précondition afin qu’aucun autre acteur ne puisse modifier l’enregistrement entre l’inspection et la mutation. Une précondition non satisfaite renvoie en revue ; ce n’est pas une raison pour supprimer la condition et réessayer.
Une approbation humaine est un état persistant, pas un verrou de base de données maintenu. Persistez la requête et libérez les ressources pendant l’attente. Les verrous PostgreSQL durent normalement jusqu’à la fin de la transaction ; laisser une transaction ouverte pendant qu’une personne décide crée de la contention sans pour autant intégrer le système distant à cette transaction.
3. Donner à l’intention une identité durable
Choisissez un identifiant d’opération une seule fois pour l’intention métier acceptée, avant le premier envoi. Une nouvelle tentative conserve cet identifiant. Limitez son unicité au tenant et stockez avec lui un hash de la requête. Même clé et même hash signifient un rejeu ; même clé avec un hash différent constitue un conflit qui doit être rejeté, et non interprété comme une mise à jour.
La structure de stockage minimale ci-dessous sépare le registre des commandes de l’outbox. L’outbox signifie que valider une commande valide aussi l’obligation de l’envoyer. Cela ne signifie pas que l’action en aval a eu lieu. Ne stockez que les champs nécessaires pour exécuter et auditer l’opération ; le stockage effectif des payloads exige du chiffrement, des restrictions d’accès et une politique de conservation.
CREATE TABLE agent_command (
tenant_id uuid NOT NULL,
operation_id uuid NOT NULL,
request_hash bytea NOT NULL,
command jsonb NOT NULL,
state text NOT NULL CHECK (state IN (
'pending', 'dispatching', 'succeeded',
'denied', 'expired', 'unknown'
)),
provider_receipt text,
lease_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, operation_id)
);
CREATE TABLE command_outbox (
tenant_id uuid NOT NULL,
operation_id uuid NOT NULL,
available_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, operation_id),
FOREIGN KEY (tenant_id, operation_id)
REFERENCES agent_command (tenant_id, operation_id)
);
4. Admettre une fois, envoyer avec un bail limité
La transaction d’admission insère ensemble la ligne du registre et son entrée dans l’outbox. Le ON CONFLICT DO NOTHING de PostgreSQL empêche une clé primaire en double de créer une seconde commande. RETURNING n’inclut que les lignes réellement insérées : seule la première admission ajoute donc une obligation d’envoi. Utilisez des paramètres liés sur une seule connexion à la base de données, avec un délai d’expiration de transaction et un rollback en cas d’erreur.
Cet extrait suppose que l’authentification, l’autorisation, les contrôles d’approbation et la canonicalisation du payload ont déjà réussi. Il illustre uniquement l’admission. L’isolation au niveau des lignes, les requêtes de dispatch, le chiffrement et les permissions liées au cycle de vie sont volontairement hors du périmètre ; une colonne tenant_id ne constitue pas à elle seule un contrôle d’isolation entre tenants.
BEGIN ISOLATION LEVEL READ COMMITTED;
WITH admitted AS (
INSERT INTO agent_command (
tenant_id, operation_id, request_hash, command, state
) VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (tenant_id, operation_id) DO NOTHING
RETURNING tenant_id, operation_id
)
INSERT INTO command_outbox (tenant_id, operation_id)
SELECT tenant_id, operation_id FROM admitted;
SELECT request_hash, state, provider_receipt
FROM agent_command
WHERE tenant_id = $1 AND operation_id = $2;
COMMIT;
Comparez le hash renvoyé au hash soumis avant de confirmer l’admission. Une différence correspond à un conflit de type HTTP 409. La seconde instruction dispose d’un nouveau snapshot en Read Committed : elle peut donc voir une insertion concurrente une fois que le contrôle d’unicité l’a attendue. Ne supprimez pas la ligne du registre tant qu’un rejeu peut encore arriver ; les nouvelles tentatives de transaction à des niveaux d’isolation plus stricts exigent leur propre traitement.
Un worker réserve une commande en attente dans une transaction courte, passe son état à dispatching et enregistre un bail à durée limitée avant le commit. Des workers concurrents peuvent utiliser le verrouillage de lignes et SKIP LOCKED pour prélever dans la file. La réservation doit vérifier l’état courant de manière atomique, et la finalisation doit être conditionnée au jeton de réservation du worker. Le stockage de production nécessite donc un jeton de réservation ou une génération de fencing en plus du schéma simplifié présenté ici.
Libérez les verrous de base de données avant l’appel réseau. Le système distant ne peut pas participer à cette transaction locale. L’expiration d’un bail ne prouve pas que le worker d’origine s’est arrêté, et un jeton de fencing local ne peut pas bloquer une API en aval qui ne le comprend pas. La reprise doit suivre le contrat d’idempotence du système en aval, et non simplement remettre chaque bail expiré à pending.
5. Unknown est un résultat à part entière
Un timeout signifie que l’appelant n’a pas obtenu de réponse définitive. Il ne signifie pas que le serveur a effectué un rollback. Si le fournisseur prend en charge l’idempotence, envoyez la clé d’opération stable avec la même requête, en respectant la portée et la durée de conservation de la clé documentées par le fournisseur. Enregistrez durablement le reçu obtenu avant d’acquitter l’élément de la file.
Un crash entre le succès distant et l’écriture du reçu local reste possible. La reprise peut interroger le fournisseur à l’aide de la clé d’opération, ou répéter exactement la même requête si ce fournisseur garantit la sûreté du rejeu. Si aucune de ces options n’est disponible, passez la commande à unknown et exigez un rapprochement. Générer une nouvelle clé pour échapper à l’incertitude crée précisément l’effet de bord en double que le registre devait empêcher.
Ne relancez pas le planner pour reconstruire une nouvelle tentative. Une nouvelle réponse du modèle peut choisir un autre payload ou une autre cible, même avec le même prompt. Rejouez la commande persistée et enregistrez chaque tentative d’exécution séparément. Une fois qu’une commande est dans un état terminal, toute livraison ultérieure doit renvoyer son résultat enregistré sans créer de nouvelle entrée dans l’outbox.
Une contrainte d’unicité en base de données et une outbox assurent une intention durable et un dispatch maîtrisé. Elles ne créent pas comme par magie des effets de bord exactly-once dans un système tiers. Ce résultat plus fort dépend de l’opération en aval, de son atomicité, de son comportement de déduplication et de la durée pendant laquelle elle conserve la clé.
6. Borner l’exécution, puis instrumenter les frontières
Un workflow a besoin de budgets pour les tokens du modèle, les appels d’outils, le temps écoulé et le travail concurrent. Appliquez-les dans le code, en dehors du planner. Réservez le budget de manière atomique avant d’admettre du travail parallèle, afin que plusieurs workers ne puissent pas tous voir le même solde restant. Libérez les réservations inutilisées après un événement de fin faisant autorité ; intégrez les nouvelles tentatives, le retrieval et la revue humaine dans le modèle de coûts d’exploitation.
Utilisez des spans OpenTelemetry pour le retrieval, la planification, l’autorisation, l’admission et le dispatch. Propagez le contexte à travers la file lorsque c’est pertinent, et utilisez des liens de spans pour relier des tentatives asynchrones ou des traces qui reprennent plus tard. Placez les identifiants d’opération dans des attributs de trace maîtrisés ou dans un registre d’audit à accès contrôlé, et non dans des labels de métriques non bornés.
Une trace n’est pas le registre d’audit. Les traces peuvent être échantillonnées, abandonnées ou supprimées selon une autre politique de conservation. L’enregistrement durable doit établir quelle commande approuvée a été tentée, quelle décision de politique l’a autorisée, ce que le système en aval a signalé et si un rapprochement a eu lieu. Les prompts bruts, les jetons d’accès et les enregistrements métier complets ne devraient pas constituer le contenu par défaut de la télémétrie.
Les changements de modèle doivent préserver ces contrats d’exécution. Comparez les candidats sur un jeu d’évaluation fixe et versionné, incluant les refus, les escalades et la précision des arguments d’outils. Une évaluation par rejeu doit remplacer les mutations par des stubs ; une exécution en shadow ne doit déclencher aucune écriture en production. Une meilleure qualité rédactionnelle ne justifie pas d’élargir le périmètre d’un outil.
7. Tester les points où le processus peut se scinder
La suite de tests de défaillance doit interrompre l’exécution à des frontières précises : avant le commit de l’admission, après le commit mais avant le dispatch, après l’écriture distante mais avant la persistance du reçu, et après la persistance mais avant l’acquittement de la file. Ajoutez des nouvelles tentatives concurrentes avec la même clé, des hash de payload discordants et deux workers en concurrence pour une même commande.
Les assertions portent sur l’état et les effets, pas seulement sur les codes de statut HTTP. Il doit y avoir une seule commande admise par clé propre à un tenant ; une différence de hash ne doit jamais modifier son payload ; un succès enregistré ne doit pas être redistribué. Avec un fournisseur simulé doté d’un contrat d’idempotence valide, le rejeu doit produire un seul effet. Avec un fournisseur non idempotent et une réponse perdue, le résultat attendu est une commande non résolue, pas un succès inventé.
Faites aussi expirer l’approbation pendant que la commande est en file, révoquez l’accès avant le dispatch, modifiez la révision de la ressource et placez du texte ressemblant à des instructions dans les documents récupérés. Vérifiez qu’aucun de ces chemins n’étend les capacités du connecteur. Testez l’épuisement du budget alors que des étapes parallèles sont encore actives, et assurez-vous que le runtime cesse de planifier de nouvelles tâches au lieu de laisser le modèle négocier une nouvelle tentative.
Voilà l’exigence d’ingénierie que j’appliquerais : le système sait expliquer ce qui a été proposé, ce qui a été autorisé, ce qui a été tenté et ce dont on sait qu’il s’est produit. Le modèle peut changer. La frontière autour de l’action métier doit rester explicite.
Sources et contexte
Architecture de référence et code illustratif, et non la description d’un runtime Pedalion livré ni une implémentation prête pour la production. La sécurité, la mutualisation, la conservation et les contrats fournisseurs nécessitent un examen propre à chaque implémentation.



