Guide · IA & agents
Support client : lire, proposer, attendre
L’agent lit un ticket local et propose un brouillon. L’enregistrement attend une personne. Aucun message n’est envoyé au client.
Ce flux ne répond pas au client. Il lit un ticket que vous avez déjà le droit de traiter, propose un brouillon, puis s’arrête. Une personne lit la proposition. Si elle refuse, rien n’est écrit. Si elle accepte, le script enregistre un fichier local. Il n’envoie pas de courriel, n’appelle pas de helpdesk, et ne ferme pas le ticket.
Le modèle peut proposer un texte qui sonne juste et qui est faux, ou un geste que vous ne voulez pas faire. L’approbation reste dans le flux, y compris après un essai qui s’est bien passé. Le stack est l’Agents SDK OpenAI, page Human-in-the-loop. Les commandes n’ont pas été rejouées le 2026-10-18.
Architecture retenue
Le modèle ne possède pas le ticket et ne parle à aucun client. Un outil local lit un JSON autorisé. Le modèle compose une proposition. Un second outil, séparé, écrit le brouillon seulement après une interruption et une décision humaine. Aucun outil d’envoi n’est enregistré auprès de l’agent. Cette séparation limite les effets d’une mauvaise réponse, mais elle ne garantit pas la qualité du texte.
Le ticket lu par l’outil rejoint ensuite le contexte du modèle. Il quitte donc la machine lors de l’appel API, même si la source et le brouillon sont locaux. Retirez les données inutiles, appliquez les règles d’accès de votre organisation et vérifiez les contrôles de conservation du projet OpenAI. L’API n’utilise pas les données pour entraîner les modèles par défaut, selon la page officielle, mais des journaux de surveillance des abus peuvent conserver du contenu jusqu’à 30 jours par défaut. Les réglages Zero Data Retention demandent une éligibilité et une configuration ; ce script ne les vérifie pas.
Prérequis
pip install openai-agents.OPENAI_API_KEYdans l’environnement.- Un ticket fictif ou un ticket que votre organisation vous autorise à coller dans un appel API. Le texte part au fournisseur du modèle, et il sera aussi dans les arguments de l’outil, donc à l’écran.
- Aucun identifiant client réel dans le premier essai. Le script d’exemple de la page SDK n’est pas un service HTTP. Celui-ci non plus.
La page dit que needs_approval existe sur function_tool, sur Agent.as_tool, sur ShellTool et sur ApplyPatchTool. L’exemple de la page importe tool depuis agents.decorators. Si cet import échoue avec la version installée, reprenez le décorateur montré sur la page plutôt que d’en improviser un. Ce point n’a pas été rejoué.
Étape 1 : un ticket sur disque, pas une conversation inventée
Créez tickets/ticket-local.json. Remplacez les deux chaînes. N’y mettez pas un échange client recopié depuis un vrai dossier si vous n’avez pas le droit de l’envoyer au modèle.
{
"id": "ticket-local",
"sujet": "remplacez ce sujet",
"message": "remplacez ce message par un texte de test"
}
Le nom de fichier sans extension est l’identifiant. Le script n’accepte qu’une chaîne courte de lettres minuscules, chiffres et tirets, pour ne pas construire un chemin qui sort du dossier.
Vérification : le fichier est sous tickets/, et vous pouvez expliquer en une phrase ce que le message demande. Si vous ne le pouvez pas, l’agent ne le pourra pas non plus sans inventer.
Étape 2 : lire sans approbation, enregistrer seulement après
lire_ticket n’a pas needs_approval. Il doit pouvoir s’exécuter pour que vous voyiez ensuite la proposition. enregistrer_brouillon a needs_approval=True. Le runner n’appelle pas le handler tant que vous n’avez pas approuvé cet appel. La proposition est dans les arguments de l’interruption, pas dans un message déjà envoyé.
always_approve ne doit pas être utilisé ici. La page dit qu’un accord collant vaut pour les appels suivants du même outil pendant le run. Pour un brouillon client, chaque appel se relit.
import asyncio
import json
import re
from pathlib import Path
from agents import Agent, Runner
from agents.decorators import tool
TICKETS = Path("tickets")
DRAFTS = Path("brouillons")
SLUG = re.compile(r"[a-z0-9-]{1,64}")
@tool()
async def lire_ticket(ticket_id: str) -> str:
"""Lit un ticket JSON local. N’écrit rien et ne contacte personne.
Args:
ticket_id: Identifiant égal au nom de fichier, sans extension.
"""
if not SLUG.fullmatch(ticket_id):
return "Identifiant refusé. Ne pas inventer le ticket."
path = (TICKETS / f"{ticket_id}.json").resolve()
if path.parent != TICKETS.resolve() or not path.is_file():
return "Ticket introuvable. Ne pas inventer son contenu."
return path.read_text(encoding="utf-8")
@tool(needs_approval=True)
async def enregistrer_brouillon(ticket_id: str, texte: str) -> str:
"""Enregistre un brouillon local après approbation. N’envoie rien au client.
Args:
ticket_id: Identifiant du ticket déjà lu.
texte: Réponse proposée, encore non envoyée.
"""
if not SLUG.fullmatch(ticket_id):
return "Identifiant refusé. Brouillon non écrit."
DRAFTS.mkdir(exist_ok=True)
path = (DRAFTS / f"{ticket_id}.txt").resolve()
if path.parent != DRAFTS.resolve():
return "Chemin refusé. Brouillon non écrit."
path.write_text(texte, encoding="utf-8")
return (
"Brouillon enregistré sur disque. "
"Aucun message n’a été envoyé au client."
)
agent = Agent(
name="Preparation support",
instructions=(
"Tu prépares un brouillon pour une personne du support. "
"Tu ne remplaces pas cette personne et tu ne contactes pas le client. "
"Appelle lire_ticket, puis enregistrer_brouillon avec le texte proposé. "
"Si le ticket est introuvable, dis-le. N’invente pas le problème. "
"N’affirme jamais que le client a reçu une réponse."
),
tools=[lire_ticket, enregistrer_brouillon],
)
async def main() -> None:
result = await Runner.run(
agent,
"Prépare un brouillon pour le ticket ticket-local. N’envoie rien.",
)
while result.interruptions:
state = result.to_state()
for interruption in result.interruptions:
print(interruption.name)
print(interruption.arguments)
answer = input("Approuver cet appel ? oui / non : ").strip().lower()
if answer == "oui":
state.approve(interruption)
else:
state.reject(
interruption,
rejection_message=(
"Brouillon refusé par la personne. "
"N’enregistre rien et ne contacte pas le client."
),
)
result = await Runner.run(agent, state)
print(result.final_output)
asyncio.run(main())
Le défaut humain est le refus : toute réponse autre que oui appelle state.reject. La page permet un rejection_message par appel. Il prime sur un formateur de run si vous en ajoutez un plus tard. Reprenez avec le même agent de premier niveau, Runner.run(agent, state), pas avec un agent neuf.
La page décrit aussi une règle callable, qui renvoie vrai ou faux selon les arguments. Elle échoue fermée si les arguments sont absents, vides, mal formés, ou pas un objet JSON. L’appel demande alors une approbation manuelle, sans exécuter l’outil. Ce script n’utilise pas cette forme : tout enregistrement attend une personne, à chaque fois.
Vérification : avant de taper oui, lisez arguments. Le brouillon doit coller au ticket. S’il ajoute un remboursement, un délai ou une cause que le fichier ne contient pas, répondez non.
Étape 3 : ce qui doit être vrai après le choix
Si vous avez refusé, brouillons/ticket-local.txt ne doit pas exister. Si vous avez accepté, le fichier contient le texte des arguments, pas un autre. Le ticket et la proposition ont déjà transité par l’API du modèle, mais aucun message n’a été envoyé au client. Le retour de l’outil confirme seulement ce dernier point. Croyez le disque, pas la phrase finale de l’agent : final_output peut reformuler.
S’il n’y a aucune interruption, l’agent n’a pas proposé l’outil d’enregistrement. Ne prenez pas final_output pour un brouillon approuvé. Relancez, ou corrigez la consigne. Sans interruption, personne n’a relu la proposition dans ce flux.
La page dit que vous pouvez ne résoudre qu’une partie des interruptions. Celles qui restent mettent le run en pause au tour suivant. Ici, traitez chaque ligne avant de reprendre, pour ne pas laisser un enregistrement en attente sans décision.
Vérification : le fichier de brouillon apparaît seulement après oui. Un second lancement qui refuse n’écrase pas un brouillon déjà accepté si vous avez répondu non : le handler ne doit pas avoir tourné. Si le fichier change quand même, needs_approval n’a pas été appliqué à cet outil.
Ce qui peut casser
Mettre needs_approval sur lire_ticket inverse le flux. Vous approuvez une lecture avant d’avoir la proposition, ou vous la bloquez. L’approbation utile est sur l’écriture.
always_approve=True sur le premier brouillon ferait passer les suivants du même outil sans nouvelle lecture, pour le reste du run. La page dit que cette décision collante survit à la sérialisation de l’état. Ne l’activez pas pour du support.
Sérialiser result.to_state() écrit l’état du run, dont les arguments d’outils. La page prévient que cet état contient le contexte applicatif et des métadonnées, et que l’exemple serveur n’est pas un service déployable. Elle demande, pour une application réelle, l’authentification du relecteur, des protections de requête, une conservation de l’état, et une reprise qui réconcilie les effets de bord. Ce brouillon ne sérialise pas l’état et n’ouvre pas de port. Si vous le faites plus tard, la personne qui approuve doit être authentifiée par votre application. Posséder un identifiant de run n’est pas une autorisation. Ne chargez pas un état envoyé par le client.
Un outil d’envoi réel, SMTP ou API de helpdesk, n’est pas dans ce script. S’il est ajouté un jour, il doit avoir sa propre approbation, après le brouillon, pas à la place. Il faut aussi rendre l’envoi idempotent et vérifier le résultat auprès du helpdesk avant de marquer le ticket comme traité. Un refus de brouillon ne doit pas déclencher un envoi « quand même » dans final_output.
Le modèle peut ignorer lire_ticket et proposer un texte générique. C’est pour cela que la personne compare les arguments au JSON. Le SDK met le run en pause. Il ne force pas la lecture attentive.
Comment vérifier
- Ticket de test en place. Lancement. Vous voyez
enregistrer_brouillonet ses arguments avant toute frappeoui. - Réponse
non. Le dossierbrouillons/ne contient pas de fichier nouveau. - Autre lancement, réponse
oui, après relecture. Le fichier local apparaît. Aucun envoi réseau vers un client n’est codé dans le handler : confirmez-le en lisant la fonction, pas en croyant le modèle. - Cas sans interruption : ne traitez pas la sortie comme une réponse client.
Ces essais n’ont pas été faits le 2026-10-18. Le flux documenté s’arrête au brouillon approuvé. Il ne remplace pas la personne qui parle au client.
Sources
Ces pages ont été relues le 2026-10-19. needs_approval, interruptions, to_state, approve, reject et rejection_message viennent de la page Human-in-the-loop. L’import agents.decorators est celui de cette page. Il n’a pas été rejoué. Aucun transcript de client n’est fourni : le ticket de test est un gabarit à remplir.