# Tester les échecs des outils d'agents : détecter tôt les nouvelles tentatives dangereuses

Les tests des échecs d'outils d'agents doivent surtout vérifier ce que l'agent croit après l'échec d'une opération, et pas seulement si l'outil a émis une erreur. Un outil qui signale « la requête a échoué » après une écriture distante qui a peut-être abouti crée un problème plus grave qu'un outil qui s'arrête avec une réponse incomplète. Les agents planifient leurs actions à partir du résultat que vous leur fournissez.

Le scénario nominal masque les décisions qui déterminent si une exécution autonome est sûre : réessayer, demander une approbation, renouveler un accès, réparer les données ou s'arrêter. J'ai vu des suites d'outils avec des centaines de tests au vert qui n'avaient jamais provoqué de coupure réseau entre le démarrage d'une commande distante et le retour de sa sortie. Ces suites ne testaient pas la partie dangereuse.

## La réponse à un échec devient une entrée pour le planificateur de l'agent

Un agent considère le résultat d'un outil comme un élément de preuve. Si le résultat laisse penser qu'aucune modification n'a eu lieu, l'agent peut réessayer. S'il indique qu'un identifiant a expiré, l'agent peut chercher une procédure de récupération autorisée. S'il indique une réussite alors que le résultat distant est inconnu, l'agent peut fonder plusieurs actions ultérieures sur une fiction.

Séparez les échecs selon ce que l'appelant peut savoir. Cette distinction est constamment brouillée :

- Un refus confirmé signifie que le service distant a reçu la requête et l'a rejetée.
- Un échec confirmé signifie que le service distant a renvoyé un résultat indiquant qu'il n'a pas effectué le travail demandé.
- Un résultat incertain signifie que l'appelant ne peut pas établir si le système distant a effectué le travail.
- Un échec local signifie que l'outil a échoué avant de pouvoir effectuer une tentative distante significative.

Un refus de connexion avant l'ouverture d'une session TCP correspond généralement à un échec local. Une réponse HTTP 403 est un refus confirmé. Un délai de lecture dépassé après l'envoi d'un `POST` produit un résultat incertain, sauf si le service distant permet de rechercher l'opération. Ces catégories doivent apparaître dans vos cas de test et dans le schéma de résultat de l'outil. Ne les dissimulez pas dans une phrase que l'agent devrait interpréter.

Une structure de résultat compacte rend le contrat testable :

```json
{
  "ok": false,
  "category": "outcome_unknown",
  "operation": "create_deployment",
  "retry": "reconcile_first",
  "correlation_id": "case-ssh-017",
  "message": "Connection closed after the remote command started; remote completion is unknown."
}
```

Les noms importent peu. La séparation, elle, est essentielle. `retry: "never"` pour un refus d'autorisation et `retry: "reconcile_first"` pour une écriture dont le délai a expiré indiquent des choses différentes à l'agent, sans transformer tout le message d'erreur en prompt.

Ne renvoyez pas les erreurs brutes du fournisseur comme seule interface. Elles changent, contiennent souvent du texte sans intérêt et peuvent parfois inclure des éléments de la requête que vous ne devriez pas transmettre à un agent. Conservez le statut, le corps et les en-têtes d'origine dans des diagnostics protégés. Renvoyez à l'appelant un résultat stable et volontairement limité.

## Construisez la matrice autour des opérations et des preuves

Une matrice utile croise chaque opération avec les modes d'échec susceptibles d'en changer le sens. Commencez par répertorier les outils qui lisent, créent, mettent à jour, suppriment, déclenchent ou exécutent. Une lecture dont le délai expire n'a pas la même règle de récupération qu'une commande qui modifie un hôte de production.

Utilisez cette matrice comme point de départ. Remplacez les noms d'opérations et les résultats attendus par les vôtres, mais ne supprimez pas la colonne « effet distant connu ». Elle force les cas inconfortables à apparaître clairement.

| Cas | Opération | Condition injectée | Effet distant connu ? | Catégorie attendue | Instruction pour l'agent |
| --- | --- | --- | --- | --- | --- |
| C01 | lire un ticket | Échec de la résolution DNS | oui, aucune requête envoyée | local_failure | réessayer dans une limite définie |
| C02 | créer un ticket | Jeton expiré | oui, requête rejetée | authentication_failed | s'arrêter et demander une récupération d'identifiant autorisée |
| C03 | supprimer une version | Permission refusée | oui, requête rejetée | authorization_denied | ne pas réessayer |
| C04 | lire une compilation | Le JSON contient `status: 7` | oui, réponse reçue | malformed_response | s'arrêter et signaler une incompatibilité de schéma |
| C05 | créer un déploiement | Réponse retardée au-delà de la limite du client | non | outcome_unknown | réconcilier avant de réessayer |
| C06 | exécuter un redémarrage SSH | L'assistant local s'est arrêté après le démarrage distant | non | outcome_unknown | vérifier l'état distant avant une autre commande |
| C07 | mettre à jour un enregistrement | Le service renvoie 429 | oui, requête rejetée | rate_limited | attendre selon les instructions, puis réessayer si c'est sûr |

Ajoutez des lignes pour les opérations qui dépensent de l'argent, modifient des permissions, renouvellent des identifiants ou touchent à un état partagé. Ces opérations nécessitent plusieurs lignes liées aux délais d'attente. Testez un délai dépassé avant que les octets ne quittent le processus, après l'envoi des en-têtes, après l'acceptation de la requête par le service et pendant l'arrivée du corps de la réponse. Les points d'injection précis dépendent du protocole, mais les réduire à un cas générique de « délai dépassé » fait perdre le comportement à vérifier.

Chaque ligne nécessite quatre assertions :

1. Vérifiez la catégorie du résultat de l'outil et l'instruction de nouvelle tentative.
2. Vérifiez l'action suivante de l'agent, notamment l'absence de nouvelle tentative destructive improvisée.
3. Vérifiez l'état distant ou la raison documentée pour laquelle il reste inconnu.
4. Vérifiez que la piste d'événements contient l'identifiant de corrélation et le résultat observé.

Cela demande plus de travail que de vérifier `ok == false`. Cela permet aussi de détecter les problèmes qui comptent après que l'agent a déjà effectué plusieurs actions.

## Les identifiants expirés et les actions refusées exigent des récupérations différentes

Un identifiant expiré ou révoqué prouve que l'authentification a échoué. Une action refusée prouve que l'appelant a été authentifié, mais qu'il n'a pas l'autorisation d'effectuer cette opération, sauf si le fournisseur masque volontairement la distinction. Traiter les deux cas comme un simple « échec d'accès » entraîne un mauvais comportement de l'agent.

La norme RFC 9110 définit 401 comme une requête non authentifiée et exige que le serveur envoie un défi `WWW-Authenticate`. Elle définit 403 comme un refus d'exécuter la requête, même lorsque le serveur n'en révèle pas la raison. Les fournisseurs ne respectent pas toujours clairement cette distinction, alors testez la réponse réelle du fournisseur. Votre outil doit néanmoins faire correspondre les éléments observés à des catégories distinctes lorsqu'il peut le faire honnêtement.

Pour tester un identifiant expiré, utilisez un identifiant accepté pendant la préparation, puis rejeté lors de l'appel réel. Une chaîne factice que le service n'a jamais reconnue ne teste que la branche d'un identifiant invalide. Vous voulez détecter les différences de comportement des caches, du code de renouvellement et des convertisseurs d'erreurs lorsqu'un jeton d'accès a réellement expiré.

Un simple scénario de test peut représenter les deux cas sans exposer de secret :

```yaml
cases:
  - id: expired-token
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 401
      headers:
        www-authenticate: Bearer error="invalid_token"
      body: {"error":"token_expired"}
    expect:
      category: authentication_failed
      retry: never
      secret_in_result: false

  - id: denied-release
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 403
      body: {"error":"insufficient_scope"}
    expect:
      category: authorization_denied
      retry: never
      secret_in_result: false
```

L'assertion `secret_in_result` détecte une erreur qui apparaît pendant un débogage précipité : le code ajoute à une exception l'en-tête d'autorisation envoyé ou l'objet de configuration. Testez la sortie sérialisée de l'outil, les traces et la transcription qui parvient à l'agent. La suppression d'un secret dans un journal ne protège pas les autres.

Ne demandez pas à l'agent « d'essayer un autre identifiant » sauf si votre système lui fournit explicitement une identité distincte et autorisée. La sélection aveugle d'identifiants peut franchir une limite de privilège tout en donnant l'impression de résoudre un problème de disponibilité. Un test doit prouver qu'un échec d'authentification arrête l'exécution ou l'oriente vers la procédure de récupération humaine approuvée.

## Les données malformées exigent un test de contrat, pas seulement un test de parseur JSON

Les données malformées comprennent les JSON valides que votre code ne peut pas utiliser sans risque. La syntaxe invalide est le cas le plus simple. En production, les problèmes viennent plus souvent d'un champ dont le type change, de la disparition d'un identifiant obligatoire, du remplacement d'une enveloppe de réussite par une enveloppe d'erreur ou d'une réponse tronquée après la fermeture d'une connexion par un proxy.

La spécification JSON-RPC 2.0 sépare les erreurs d'analyse (`-32700`) des requêtes invalides (`-32600`). Cette distinction est utile, car elle sépare des octets illisibles d'un message lisible qui viole le protocole. Appliquez la même rigueur à vos réponses métier : la réussite du parseur ne prouve pas que la réponse respecte le contrat de l'outil.

Pour chaque réponse de fournisseur que vous consommez, écrivez des scénarios qui violent une seule hypothèse à la fois :

- Remplacez un identifiant texte par `null`, un nombre puis un objet.
- Omettez un champ dont les appels suivants ont besoin pour la réconciliation.
- Renvoyez un statut de réussite avec un corps ayant la forme d'une erreur.
- Renvoyez un statut d'erreur avec un corps HTML ou un document JSON tronqué.
- Dupliquez un élément ou modifiez l'ordre lorsque votre code sélectionne le premier élément.

Vérifiez ensuite le comportement exact. L'outil doit nommer le champ ou la condition de contrat en échec dans les diagnostics protégés, renvoyer `malformed_response` à l'agent et n'effectuer aucune modification ultérieure à partir de valeurs devinées.

Un mauvais schéma courant semble inoffensif : `response.id || request.id`. Il permet au workflow de continuer lorsqu'un fournisseur omet `id`, mais peut provoquer une mise à jour ou une suppression sur un objet sans rapport si l'identité de la requête et celle de la réponse diffèrent. Testez que l'absence de l'identité dans la réponse arrête l'opération. Un workflow interrompu coûte moins cher qu'une écriture incorrecte.

Les clients d'outils MCP exigent la même attention. Le format de résultat des outils du Model Context Protocol prend en charge le signal `isError` pour un échec au niveau de l'outil. Utilisez-le lorsque l'outil lui-même ne peut pas effectuer le travail promis, tout en conservant un contenu suffisamment précis pour que l'agent choisisse une branche sûre. Ne masquez pas une réponse amont malformée derrière un résultat textuel normal commençant par « Error: ». De nombreux clients considéreront alors l'exécution de l'outil comme réussie et laisseront l'agent deviner la suite.

## Les délais d'attente deviennent ambigus dès qu'une écriture commence

Un délai d'attente vous indique que votre limite a expiré. Il ne révèle pas l'état de l'opération distante. Cela semble évident jusqu'à ce qu'une boucle de nouvelles tentatives transforme discrètement une réponse perdue en facture dupliquée, en deux déploiements ou en un second redémarrage.

Testez le comportement à la frontière où la certitude change. Votre injecteur d'erreurs ou votre faux service doit enregistrer chaque étape :

```text
case=C05 request_id=case-http-005 received=true
case=C05 request_id=case-http-005 mutation_committed=true
case=C05 response_write=delayed
client case=C05 deadline_exceeded=true
```

L'assertion attendue n'est pas « le client a reçu un délai d'attente ». Elle vérifie que le client renvoie `outcome_unknown`, n'émet pas un second `POST` et utilise une recherche d'état ou un mécanisme d'idempotence avant de poursuivre.

Les jetons d'idempotence ne sont utiles que si l'API distante les documente et les respecte pour l'opération concernée. Testez-les comme une séquence complète : envoyez une requête avec un jeton unique, retardez la première réponse jusqu'à ce que l'appelant abandonne, envoyez le même jeton via la procédure de récupération, puis vérifiez que le service signale une seule opération logique. Ne prétendez pas qu'une opération est idempotente parce que vous avez ajouté un en-tête ignoré par le fournisseur.

Pour les opérations sans point de réconciliation ni prise en charge de l'idempotence, indiquez-le dans le résultat de l'outil. L'action sûre peut consister à s'arrêter et à demander à une personne d'inspecter le système distant. Ce n'est pas un échec d'ingénierie. Prétendre être certain parce qu'un workflow veut continuer en est un.

Définissez des délais par phase lorsque votre client le permet : connexion, écriture de la requête, premier octet de réponse et durée totale de l'opération. Une seule limite importante masque le fait qu'un pair n'a jamais accepté la connexion ou qu'il a accepté l'écriture avant de se bloquer. Vos tests n'ont pas besoin d'exposer chaque phase à l'agent, mais vos diagnostics doivent contenir suffisamment de détails pour qu'un opérateur puisse reproduire l'événement.

## Les commandes distantes interrompues doivent conserver l'incertitude

Une commande SSH présente une fenêtre d'échec que les développeurs HTTP sous-estiment souvent. Le client peut envoyer la commande, le shell distant peut la démarrer, puis la connexion peut se fermer avant que l'appelant ne reçoive le code de sortie. Le crash d'un processus local ou la perte d'une route réseau n'annule pas le travail déjà commencé sur l'hôte distant.

OpenSSH précise que son client renvoie le code de sortie de la commande distante lorsqu'il peut l'obtenir. Lorsque le transport se rompt avant cela, l'appelant ne dispose pas de ce code. Testez volontairement ce cas au lieu de considérer qu'un code de sortie local non nul prouve l'échec de la commande distante.

Créez une commande de test distante qui écrit un marqueur de début, attend, écrit un marqueur de fin et produit un résultat reconnaissable. Arrêtez ensuite le transport local pendant l'attente. Limitez ce test à un hôte ou à un conteneur isolé qui vous appartient.

```sh
# remote command used only in an isolated test environment
id="case-ssh-017"
printf '%s start\n' "$id" >> /tmp/agent-tool-test.log
sleep 20
printf '%s complete\n' "$id" >> /tmp/agent-tool-test.log
```

Exécutez la commande par le même chemin SSH que celui utilisé par l'outil, attendez l'apparition du marqueur de début, puis arrêtez l'assistant local. Une fois l'attente distante terminée, inspectez le journal. Exécutez le test deux fois : une fois lorsque le processus distant termine son travail et une fois lorsque le système distant l'arrête après le marqueur de début. Les deux cas produisent une interruption locale, mais exigent des récupérations différentes.

Pour les commandes qui modifient l'état, concevez une commande de réconciliation avant de concevoir une nouvelle tentative. Un redémarrage de service peut interroger la durée de fonctionnement du processus ou la révision du déploiement. Une installation de paquet peut interroger la version installée. Une commande qui ne peut pas être réconciliée doit nécessiter une intervention humaine explicite après une interruption.

Évitez les extraits shell qui masquent une exécution partielle derrière des chaînes `&&` et une sortie vague. Émettez un identifiant d'opération durable avant le début de la partie modificatrice et réutilisez cet identifiant lors de l'inspection. Si l'environnement distant ne peut conserver aucun marqueur, l'outil n'a aucun fondement pour dire à un agent qu'une nouvelle tentative est sûre.

## Les refus humains sont un résultat normal, pas un test défectueux

Le refus d'une action par une personne doit produire un résultat distinct qui clôt proprement cette branche. Les équipes vérifient souvent qu'un écran d'approbation s'affiche, mais oublient de tester le refus. Les agents se mettent alors à réessayer, à reformuler la même demande ou à présenter un échec d'approbation comme un problème réseau.

Testez le refus à chaque frontière d'autorisation que vous exposez. Vérifiez que l'outil ne se connecte pas au service distant après un refus. Vérifiez qu'il ne conserve pas une approbation pour un processus ultérieur ou pour une autre action nécessitant une nouvelle décision. Vérifiez que l'agent reçoit un texte qu'il peut utiliser sans considérer le refus comme une invitation à chercher un contournement.

Le coffre de Sallyport refuse toute action lorsqu'il est verrouillé. Ses approbations de session et par appel permettent de tester ces décisions sans placer les identifiants dans le processus de l'agent. Cette séparation est utile, car un coffre verrouillé, une session refusée et une utilisation par appel refusée peuvent tous interrompre une opération pour des raisons différentes.

La lassitude face aux approbations constitue un échec de test à part entière. Si une lecture inoffensive génère des demandes répétées pendant une exécution normale, les utilisateurs finiront par approuver sans lire. Si un appel destructeur hérite accidentellement d'une approbation trop large, les utilisateurs ne voient jamais le point de décision attendu. Testez le nombre, le moment et la portée des demandes, pas seulement leur présence.

Utilisez un agent de test qui tente une action approuvée, une action refusée et une action après la fin du processus. Le dernier appel détecte les états d'approbation qui persistent au-delà de la session prévue. Ne simulez pas cela uniquement en modifiant un booléen en mémoire ; lancez un processus distinct afin que le test reproduise le cycle de vie réel de vos utilisateurs.

## Les journaux doivent expliquer ce qui s'est passé sans exposer les accès

Un enregistrement d'échec utile permet de reconstituer la causalité : quelle exécution d'agent a tenté quelle opération, quel identifiant de requête elle a utilisé, ce que le système distant a observé, ce que l'outil a renvoyé et ce que l'agent a fait ensuite. Il ne devrait pas contenir l'identifiant qui a autorisé l'appel.

Enregistrez un événement à chaque point où la réponse peut changer. Pour une écriture dont le délai expire, consignez la construction de la requête, le début de l'envoi, l'acceptation distante si votre scénario peut la signaler, l'expiration du délai, la tentative de réconciliation et la classification finale. Incluez un identifiant de corrélation généré avant la première action réseau. Ne le dérivez pas d'un secret et ne le réutilisez pas pour plusieurs opérations.

Ce format convient à un banc de test local :

```json
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"dispatch_started"}
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"remote_committed"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"client_timeout"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"result","category":"outcome_unknown"}
```

Votre assertion peut alors comparer les enregistrements du client et du scénario à l'aide de `id`. Si le scénario indique `remote_committed` alors que l'outil indique `confirmed_failure`, faites échouer le test. Ce désaccord révèle une affirmation dangereuse, même si tous les chemins de code ont renvoyé un objet d'erreur bien formé.

Pour une piste dont l'intégrité peut être vérifiée, testez aussi la vérification. Sallyport produit ses enregistrements de session et d'activité à partir d'un journal d'audit chiffré organisé en chaîne de hachage, et `sp audit verify` vérifie cette chaîne hors ligne sans nécessiter de clé de coffre. Un test d'échec doit ajouter une séquence d'événements connue, la vérifier, modifier une copie d'un enregistrement et confirmer que la vérification échoue sur la copie modifiée.

Ne placez pas par défaut les corps complets des requêtes dans les journaux ordinaires. Les données de requête contiennent souvent des informations personnelles, du code source ou des jetons ajoutés par inadvertance. Journalisez le nom de l'opération, la classification de la cible, l'identifiant de corrélation, la catégorie du résultat et une référence vers les diagnostics protégés. N'étendez la collecte que dans un environnement de test contrôlé où vous connaissez le contenu des scénarios.

## Testez le comportement de récupération de l'agent, pas seulement l'adaptateur

Les tests unitaires prouvent qu'un adaptateur convertit 403 en `authorization_denied`. Ils ne prouvent pas que l'agent s'arrête après réception de ce résultat. Exécutez une petite suite de bout en bout avec une instruction d'agent déterministe et un faux service distant qui expose le journal des événements injectés.

Donnez à chaque exécution une tâche limitée et une frontière explicite. Par exemple : créer un enregistrement, le relire, puis lui ajouter une note. Retardez la réponse de création après avoir enregistré l'enregistrement. Le comportement correct de l'agent consiste à effectuer une recherche par identifiant de corrélation ou par jeton d'idempotence avant de tenter une seconde création. Le test doit échouer s'il crée un autre enregistrement, même s'il finit par accomplir la tâche.

Conservez le prompt de l'agent inchangé pour cette suite. Si vous modifiez en même temps le prompt, le contrat de l'outil, le comportement du scénario et la version du modèle, un échec vous apprendra peu de choses. Enregistrez la transcription de l'outil et l'appel suivant de l'agent, puis comparez-les aux transitions autorisées :

```text
create -> outcome_unknown -> lookup_by_request_id -> found -> attach_note
create -> outcome_unknown -> create
```

La première transition n'est autorisée que si la recherche confirme la création initiale. La seconde est un échec. Cette vérification par machine à états est plus utile que d'évaluer si la réponse finale semblait raisonnable.

Exécutez les cas déterministes de la matrice à chaque modification du code des outils, des schémas de résultats, de la gestion des autorisations ou de la logique de nouvelles tentatives. Répétez les cas d'interruption dans une infrastructure isolée, car l'ordonnancement influe sur leur résultat. Lorsqu'un nouvel incident apparaît, ajoutez sa reproduction minimale à la matrice avant de le corriger. Sinon, le même chemin de récupération séduisant mais incorrect réapparaîtra lors de la prochaine refactorisation.

Le critère d'un outil n'est pas de continuer après chaque incident. Il doit dire la vérité sur ce qu'il sait, laisser des preuves et refuser de transformer une incertitude en seconde action destructive.
