Du manifeste au code : comment argentic-mw exécute ses agents
Dans le premier article de cette série, j’ai posé une définition et une prise de position : un agent supervisé est un worker planifié qui exécute un plan déterministe, où le LLM intervient seulement aux points d’extension où il est utile, et où tout le reste est du code explicite, traçable et rejouable. Quatre piliers en découlaient, le déterminisme, le contrôle, l’auditabilité et l’efficacité token, que je présentais comme des propriétés vérifiables sur une trace plutôt que comme des slogans. Dans le deuxième article, j’ai proposé un cadre de décision à six critères pour comparer cette approche aux plateformes full-LLM et aux frameworks d’orchestration, et j’ai conclu que pour des pipelines métier planifiés avec exigences d’audit et de coût borné, le socle sur-mesure restait la réponse cohérente, quitte à payer un coût de boot réel.
Il reste une question, et c’est la plus concrète : ces promesses tiennent-elles dans le code, ou restent-elles un manifeste élégant qui s’efface au contact de l’implémentation ? Le propos de cet article est de répondre par la pratique. Je vais décrire la fondation commune, le Socle, qui porte les trois agents de la plateforme, puis dérouler les trois pipelines comme autant d’incarnations des quatre piliers. Il y aura du schéma, un extrait de rule table et un peu de pseudo-code, parce qu’à ce stade de la série, l’abstraction a fait son travail et ce sont les mécanismes concrets qui décident si la vision tient ou si elle se dissout.
Le Socle, la fondation commune
Le socle établit les bases nécessaires à tout le reste, et il est conçu pour être assimilé en une après-midi. La décision architecturale qui le définit, l’ADR 0001, part d’un constat simple : pour trois agents cron à pipelines linéaires, un moteur de workflow serait un investissement disproportionné, et un framework d’orchestration comme LangGraph imposerait ses abstractions sans délivrer le socle métier dont j’ai besoin. J’ai détaillé ce raisonnement dans le cadre de décision du deuxième article (voir Quand choisir une plateforme LLM tout-en-un, quand construire sur-mesure) : la voie intermédiaire réduit le coût de boot sur l’orchestration, mais les traces, le replay, la file de validation et la distinction type-instance restent à construire par-dessus. Plutôt que de reconstruire un socle supervisé autour d’un framework, je l’écris directement, en environ deux cents lignes, et il fait exactement ce que les agents demandent, ni plus ni moins.
Le cœur du Socle est un exécuteur de pipeline qui tient en une boucle. Un pipeline est une liste ordonnée de Steps, et l’exécution est un for step in steps qui appelle chaque Step à la suite, trace son entrée et sa sortie, et persiste l’ensemble en base. Il n’y a pas de graphe implicite, pas de nœud caché, pas de boucle LLM-driven qui déciderait de reboucler. Le flow est lisible dans un fichier, et c’est cette lisibilité qui rend l’audit possible.
flowchart TD
SCHED[Scheduler<br/>leader Postgres<br/>lease TTL 30s] -->|dispatch run| W1[Worker stateless]
SCHED --> W2[Worker stateless]
SCHED --> W3[Worker stateless]
W1 --> EXEC[Pipeline executor<br/>for step in steps]
W2 --> EXEC
EXEC --> ST1[Step 1<br/>trace input/output]
ST1 --> ST2[Step 2<br/>trace + llm_usage?]
ST2 --> STN[Step N]
STN --> PG[(Postgres<br/>traces, replay cache,<br/>secrets, config, logs)]
Le contrat d’un Step est volontairement minimal et défini par l’ADR 0002. Concrètement, un Step est une fonction asynchrone typée : elle prend une entrée, peut produire des effets de bord (appel HTTP, écriture en base, envoi d’email), puis retourne une sortie structurée que le Socle trace et persiste.
La contrainte clé, c’est l’idempotence. Un Step doit pouvoir être rejoué sans dupliquer ses effets. En pratique, cela implique des upsert plutôt que des insert, des clés de déduplication pour les envois, ou encore des appels API idempotents (GET, ou équivalents sûrs). Ce n’est pas garanti par le système de types, mais par la discipline de code et la revue, et c’est ce qui rend le replay fiable.
Le replay repose sur un cache indexé par run_id + step_index. Quand on rejoue un run, l’exécuteur consulte le cache avant d’appeler un Step : si la sortie est déjà là, il la restitue sans ré-exécuter l’effet de bord, et si elle manque, par exemple après un crash en plein milieu, il ré-exécute le Step en s’appuyant sur l’idempotence pour éviter tout double effet. Le endpoint de replay ne crée pas un nouveau run_id, il remet le run existant à pending en place, ce qui préserve la continuité de la trace. Un balayage de staleness, piloté par le leader du scheduler, repère les runs restés à running avec un heartbeat trop ancien (au-delà de soixante secondes) et les remet à pending pour qu’ils soient repris.
Le tracing est la propriété qui transforme cette boucle en système auditable. Chaque Step trace son entrée, sa sortie et ses métadonnées, et les LLM Steps ajoutent un bloc llm_usage avec le nom du modèle, les tokens d’entrée, les tokens de sortie et la latence. La part probabiliste du système, celle qui vient du LLM, n’est donc pas diffuse : elle est localisée dans des Steps nommés, et son coût est mesuré par Step plutôt que par run. Pour un auditeur, la trace d’une exécution est un dossier ordonné, où chaque décision est rattachable à un Step, à une entrée datée et à une sortie persistée. Le Socle ne distingue pas les LLM Steps des Steps techniques au niveau du type, parce que ce sont les mêmes fonctions, mais leur trace porte l’usage, et c’est suffisant pour rendre la facture lisible.
Le scheduler est la pièce qui rend le tout opérationnel sans devenir un point de fragilité. Il est conçu en actif-passif avec élection de leader via Postgres : plusieurs instances tournent sur des nœuds différents, une seule est active à un instant donné, et le leadership s’acquiert par un bail (advisory lock ou table de lease avec un TTL de l’ordre de trente secondes). Si le nœud leader tombe, une autre instance prend le relais à l’expiration du bail, sans intervention humaine. Les workers sont stateless, et tout l’état (traces, artefacts, cache de replay, files de validation) vit en base. Ajouter de la capacité se résume à ajouter un worker sur un nouveau nœud, et n’importe quel worker peut exécuter n’importe quel run. C’est de l’ingénierie de plateforme classique, pas de l’IA, et c’est précisément le propos.
Les secrets, enfin, sont chiffrés en base et injectés dans les Steps via un conteneur de Dependencies typé, qui porte aussi les clients HTTP et l’accès à la base. Un Step ne lit jamais un secret en clair dans l’environnement : il le reçoit à travers ses dépendances, ce qui rend la surface d’exposition lisible et testable. La configuration des LLM Steps (modèle, limites de consommation, prompt système) vit elle aussi en base, éditable via le control plane sans redéploiement, ce qui sépare le comportement du code et permet d’ajuster un prompt sans ouvrir une PR.
Ces mécanismes ne sont pas exotiques. Un for step in steps, un cache indexé, une élection de leader par bail, des secrets chiffrés injectés par dépendance, c’est l’ingénierie de plateforme qu’on écrit quand on refuse d’acheter un framework pour trois pipelines cron. La valeur n’est pas dans la complexité, elle est dans la lisibilité : tout tient en deux cents lignes, et tout se lit.
Watch Agent, veille RSS/Atom
Le Watch Agent est le premier agent que j’ai écrit, et c’est celui où la frontière entre déterminisme et LLM est la plus lisible. Son pipeline est court : fetch-feeds → dedupe → classify(LLM) → summarize(LLM) → deliver. Cinq Steps, dont deux seulement font appel au LLM, et les trois autres sont du code déterministe qui ne demande aucune intelligence pour faire son travail.
Les Steps déterministes portent la logique métier qui n’a pas besoin d’un modèle. fetch-feeds récupère les flux RSS et Atom configurés pour l’instance, avec un client HTTP asynchrone et une gestion simple du retry. dedupe compare les nouveaux articles à ceux déjà persistés en base, par URL ou par hash de contenu, et ne conserve que ce qui n’a pas déjà été traité. deliver envoie le mail de veille aux destinataires configurés et persiste les articles en base pour la trace. Aucun de ces Steps ne peut surprendre : si le flux répond, on récupère ses articles ; si l’article est déjà connu, on l’écarte ; si le destinataire est configuré, on envoie. Le comportement est prévisible, et c’est précisément ce qu’on demande à la partie non-LLM d’un pipeline supervisé.
Le LLM intervient à deux endroits, là où il est utile. classify(LLM) reçoit chaque article et lui attribue un thème parmi une liste fermée configurée par instance. summarize(LLM) produit un résumé court du contenu, avec un schéma de sortie strict qui borne la longueur et le format. Les deux Steps sont des LLM Steps au sens de l’ADR 0001 : un Pydantic AI Agent avec un output_type Pydantic, un deps_type pour l’injection des dépendances, un modèle choisi pour la tâche, et des UsageLimits pour borner la consommation.
Le point qui mérite d’être souligné, parce qu’il incarne l’efficacité token par construction, c’est que les deux Steps ne partagent pas nécessairement le même modèle. La classification par thème est une tâche de catégorisation bornée, qui se satisfait d’un modèle bon marché et rapide. La synthèse d’un article demande un modèle plus solide, capable de restituer le propos sans l’appauvrir. Configurer un modèle cheap pour la classification et un modèle plus fort pour la synthèse n’est pas une optimisation après coup, c’est une propriété de la plateforme : chaque LLM Step porte sa propre configuration, et le choix du modèle est local au Step, pas global à l’agent. La même pipeline, exécuté aujourd’hui et dans six mois, consomme le même ordre de grandeur de tokens, parce que la structure ne change pas et que les limites sont par Step.
Les promesses du manifeste se lisent directement dans ce pipeline. Le coût est borné par les UsageLimits de chaque LLM Step, et la facture se prévoit au Step près. L’auditabilité vient de la trace par article : pour chaque article traité, on retrouve l’entrée du Step de classification, la catégorie produite, le modèle utilisé, les tokens consommés, et le résumé qui en a découlé. Le replay vient du cache par Step : si l’envoi du mail a échoué, on rejoue le run, les Steps de classification et de synthèse restituent leurs sorties depuis le cache, et seul le Step deliver ré-exécute son effet de bord. Le contrôle, enfin, est trivial dans ce cas : il n’y a pas d’action à risque, juste un envoi de mail, et l’interruption d’un run se résume à arrêter l’exécution.
Web Stats Agent, statistiques web
Le Web Stats Agent est l’agent où le multi-modèle par tâche prend le plus de relief, parce que son pipeline enchaîne trois LLM Steps consécutifs, chacun sur un registre différent. Son pipeline : fetch-plausible → fetch-search-console → fetch-references-stats → aggregate → compare(LLM) → recommend(LLM) → suggest-references(LLM) → deliver. Huit Steps, dont trois LLM Steps, et c’est la chaîne compare → recommend → suggest-references qui porte l’intelligence du système.
flowchart LR
A[fetch-plausible] --> B[fetch-search-console]
B --> C[fetch-references-stats]
C --> D[aggregate]
D --> E["compare<br/>(LLM Step)"]
E --> F["recommend<br/>(LLM Step)"]
F --> G["suggest-references<br/>(LLM Step)"]
G --> H[deliver]
Les quatre premiers Steps sont déterministes. fetch-plausible et fetch-search-console interrogent les analytics du site suivi via leurs API respectives, fetch-references-stats récupère les statistiques des sites de référence configurés (par scraping ou par API selon ce qu’ils exposent), et aggregate consolide tout cela en un jeu de données comparable. Aucun de ces Steps ne demande de jugement : ce sont des appels HTTP typés, des transformations de données, et une persistance en base. La partie LLM commence quand les données sont prêtes.
compare(LLM) reçoit les statistiques du site et celles des références, et produit une comparaison structurée : quels indicateurs sont en-deçà, quels indicateurs sont au-dessus, quel écart est notable. C’est une tâche de lecture de séries, qui se satisfait d’un modèle intermédiaire, capable de repérer les écarts significatifs sans surinterpréter. recommend(LLM) reçoit la comparaison et propose des actions d’amélioration (travailler telle page, viser tel mot-clé, corriger tel tunnel). C’est une tâche de synthèse qui demande un modèle plus solide, capable de formuler une recommandation actionnable et contextualisée. suggest-references(LLM) propose de nouveaux sites de référence à ajouter au pool, à partir des thématiques du site et des références déjà suivies.
Les trois Steps sont configurés indépendamment, chacun avec son modèle, ses UsageLimits et son prompt. L’efficacité token se construit dans cette granularité : on ne paie pas un modèle coûteux pour la comparaison, qui est une tâche de lecture, et on ne sous-équipe pas la recommandation, qui est une tâche de synthèse. Le système ne repose pas sur un seul modèle pour tout, ce qui serait soit coûteux soit sous-performant, mais sur un modèle par tâche, calibré sur la difficulté de la tâche.
Le point de contrôle le plus intéressant de cet agent est la file de validation humaine pour les suggestions de références. Le Step suggest-references(LLM) produit des propositions, mais il ne les ajoute pas au pool de références suivies. Les propositions entrent dans une file, et un humain doit les approuver via le control plane (REST API ou CLI) avant qu’elles ne deviennent actives. C’est l’incarnation directe du pilier contrôle : le LLM propose, l’humain décide, et la frontière est explicite dans le pipeline. Si une suggestion est mauvaise ou hors sujet, elle reste dans la file jusqu’à rejet, et n’a aucun effet sur les runs suivants. Si elle est bonne, l’humain l’approuve, et elle rejoint le pool. Le LLM n’a jamais la capacité d’élargir son propre périmètre de suivi, ce qui serait une faille de contrôle dans un système autonome.
Les promesses se matérialisent ici aussi. Le coût est borné par les UsageLimits des trois LLM Steps, et le multi-modèle évite de payer un modèle coûteux sur une tâche triviale. L’auditabilité vient de la trace : pour chaque recommandation, on retrouve les statistiques d’entrée, la comparaison produite, le modèle utilisé, et la chaîne qui mène de la comparaison à la recommandation. Le replay fonctionne au Step près, et la file de validation est elle-même persistée en base, ce qui rend l’historique des décisions humaines auditable au même titre que les décisions du LLM.
Zendesk Agent, tickets, rule table, auto vs draft
Le Zendesk Agent est l’agent le plus long à décrire, parce qu’il porte l’action la plus risquée de la plateforme : la réponse automatique à un client sans validation humaine. Son pipeline : fetch-tickets → classify(LLM) → resolve-response-mode → resolve-routing → [auto] send-auto-response | [draft] draft-response(LLM) → deliver. C’est aussi l’agent où la séparation entre classification et décision est la plus nette, et c’est là que la règle table prend tout son sens.
Le point de départ est une distinction que j’ai posée dans l’ADR 0003 et que je défends : la précision vient de la classification, qui est là où le langage naturel demande à être compris, mais la décision (auto ou draft) vient d’une règle, qui est située où le déterminisme est exigé. Le LLM classifie le ticket dans une catégorie parmi une liste fermée, et une table éditée par un humain associe chaque catégorie à un mode de réponse. Le LLM ne décide jamais si on répond automatiquement, il produit seulement une catégorie, et c’est cette asymétrie qui rend la décision auditable.
flowchart TD
T[fetch-tickets] --> CL["classify(LLM)<br/>catégorie + confiance"]
CL --> RM[resolve-response-mode<br/>lookup rule table]
RM -->|"confiance < seuil"| DR
RM -->|"catégorie → auto + garde-fous OK"| AUTO[send-auto-response<br/>template interpolé]
RM -->|"catégorie → draft"| RT[resolve-routing<br/>support / presales]
AUTO --> LG[(auto_response_log<br/>dedup_key unique)]
RT --> DR["draft-response(LLM)"]
DR --> DL[deliver<br/>draft en attente validation]
LG --> DL2[(persist trace)]
La rule table est un fichier versionné, éditable par un humain, qui mappe chaque catégorie à un response_mode (auto ou draft) et optionnellement à un template de réponse. Un extrait suffit à montrer la structure :
| category | response_mode | response_template |
|---|---|---|
| prerequisites-missing | auto | templates/prerequisites.md |
| tech-ports | auto | templates/ports.md |
| tech-prerequisites | auto | templates/tech-prereq.md |
| billing-question | draft | n/a |
| bug-report | draft | n/a |
| feature-request | draft | n/a |
| how-to | draft | n/a |
| other | draft | n/a |
Ajouter un nouveau cas de réponse automatique, c’est ajouter une ligne dans la table et un fichier de template. Pas de modification de prompt, pas de ré-entraînement, pas de redéploiement. La table est la source de vérité pour la décision, et le LLM n’a jamais la parole dessus. La catégorie other, associée à draft, est le filet de sécurité : tout ce que le LLM ne sait pas classer finit en brouillon pour validation humaine, jamais en réponse automatique.
Le Step resolve-response-mode est la concrétisation de cette logique, et son pseudo-code tient en une dizaine de lignes :
async def resolve_response_mode(
classification: TicketClassification,
ctx: StepContext,
) -> ResponseMode:
category = classification.category
confidence = classification.confidence
if confidence < ctx.config.confidence_threshold:
return ResponseMode.draft
row = ctx.rule_table.lookup(category)
if row is None or row.response_mode != "auto":
return ResponseMode.draft
if not ctx.guardrails.all_clear(category, ctx.run_id, ctx.agent_id):
return ResponseMode.draft
return ResponseMode.auto
La logique est volontairement défensive, et c’est lisible dans l’ordre des conditions. Si la confiance est basse, on bascule en draft. Si la catégorie n’est pas dans la table ou n’est pas marquée auto, on bascule en draft. Si l’un des garde-fous ne passe pas, on bascule en draft. L’état par défaut est le draft, qui est l’état sûr, et l’auto n’est atteint que si toutes les conditions sont vérifiées. C’est ce qu’on appelle un fail-closed, et pour une action qui touche un client, c’est la seule posture défendable.
Les garde-fous sont l’objet de l’amendement de l’ADR 0003, et ils méritent d’être nommés parce que l’auto-response est l’action la plus risquée du système. Le double-signal exige qu’un filtre déterministe (regex sur mots-clés) confirme la catégorie produite par le LLM : si la regex ne matche pas, on bascule en draft. Le kill switch global (ARGENTIC_AUTO_RESPONSE_ENABLED en variable d’environnement, auto_response_enabled en paramètre d’agent) permet de couper l’auto-response en un endroit sans toucher au code. Les rate caps limitent le nombre d’auto-responses par run (dix par défaut) et par agent sur vingt-quatre heures (cinquante par défaut), comptés via la table auto_response_log. Le seuil de confiance force le draft en dessous de 0.7. L’audit log enregistre chaque auto-response envoyée, avec le ticket, la catégorie, la confiance, le template utilisé, le rendu final et la clé de déduplication. La déduplication, enfin, interdit plus d’une auto-response par ticket et par template, via une clé sha256(ticket_id + template_path) avec un index unique en base.
Le risque résiduel est assumé : le double signal basé sur une regex peut être contourné si un attaquant injecte le mot-clé dans le ticket, et c’est explicitement documenté dans l’ADR. Son objectif est d’améliorer la robustesse face aux erreurs de classification non adversariales du LLM, pas de résister à un attaquant déterminé. Pour un système en contact avec de vrais clients, cette limite doit être clairement posée.
Le routing est l’étape qui suit la classification et complète la décision. Un ticket classifié est routé vers support ou presales, et l’agent vérifie l’assignation courante dans Zendesk : si elle ne correspond pas au routage attendu, il corrige via l’API. C’est un re-routing explicite, qui évite qu’un ticket avant-vente reste dans la file support par erreur d’aiguillage initiale. La correction est un PUT idempotent sur l’API Zendesk, ce qui respecte la contrainte d’idempotence de l’ADR 0002.
L’incarnation des promesses est ici la plus complète. Le déterminisme vient de la rule table, qui fait de la décision auto/draft un lookup déterministe plutôt qu’un jugement de modèle. Le contrôle vient des garde-fous et de la validation humaine systématique pour les drafts, qui sont la majorité des cas. L’auditabilité vient de l’auto_response_log, qui enregistre chaque auto-response avec son contexte complet, et de la trace par Step, qui rend la classification et la décision retrouvables. La sécurité repose sur un modèle fail-closed : le mode draft est l’état par défaut, et le passage en auto reste un chemin strictement contrôlé.
Une précision technique mérite d’être faite sur l’accès à l’API Zendesk, qui fait l’objet de l’ADR 0004. L’agent n’utilise pas le SDK Python Zenpy, mais httpx.AsyncClient directement. Le choix se justifie par la cohérence avec le reste de la stack (toutes les autres intégrations externes, Plausible, Search Console, RSS, SMTP, utilisent httpx), par le caractère async-natif de httpx qui évite de wrapper des appels synchrones dans asyncio.to_thread, et par le contrôle plein sur l’idempotence (la clé de déduplication est injectée dans les metadata du commentaire Zendesk sans fight contre une abstraction de SDK). C’est un détail d’implémentation, mais il est représentatif de la philosophie du Socle : on garde le contrôle sur les mécanismes qui comptent, et on n’introduit une dépendance que si elle délivre une valeur nette.
Ce que la plateforme ne fait pas, encore
L’honnêteté suppose de dire ce qui n’est pas là, parce que le manifeste peut donner l’impression d’un système complet, et qu’il ne l’est pas encore. La plateforme est en cours de développement, pas en production, et les chiffres de consommation réelle, de coût par run, de latence par Step, viendront quand elle tournera pour de vrai. Ce que je décris ici est l’architecture et les mécanismes, pas un retour d’expérience chiffré.
Trois limitations de périmètre sont assumées en v1. Il n’y a pas de triggers événementiels : un run se déclenche par cron ou par lancement manuel via le control plane, et les webhooks ou événements externes sont hors scope. L’ajout d’un trigger événementiel ne demande pas de changement architectural, seulement un nouveau point d’entrée dans le scheduler, mais pour l’instant le périmètre est volontairement borné. Il n’y a pas de Web UI : le control plane est une REST API consommée par une CLI, ce qui couvre toutes les opérations (configuration, lancement manuel, validation des drafts, approbation des références, métriques de tokens). Une UI future peut venir se brancher sur la même API sans rework, mais ce n’est pas la priorité de la v1, et les dashboards métriques peuvent être bâtis avec des outils externes connectés à Postgres. Enfin, le Web Stats Agent ne lit que Plausible en v1, pas Matomo ni GA4 : le périmètre analytics est volontairement réduit, et l’extension à d’autres sources se fera par ajout de Steps de fetch, sans toucher au Socle.
Ces limitations ne sont pas des défauts de conception, ce sont des bornes de périmètre. Le Socle est pensé pour que chaque extension se résume à ajouter un pipeline de Steps, et les limitations actuelles sont les premières choses à lever quand la v1 aura fait ses preuves en production.
Conclusion, la vision tient
L’objectif de cette série est d’exposer une philosophie, puis de démontrer qu’elle se vérifie concrètement dans le code. Les quatre piliers du manifeste, le déterminisme, le contrôle, l’auditabilité et l’efficacité token, ne sont pas restés au stade du slogan : ils se matérialisent dans le Socle et dans les trois pipelines. Le déterminisme se vérifie dans le for step in steps et dans la rule table du Zendesk Agent. Le contrôle se vérifie dans les garde-fous de l’auto-response, dans la file de validation des références, dans la distinction type-instance qui permet de configurer sans redéployer. L’auditabilité se vérifie dans la trace par Step, dans le bloc llm_usage des LLM Steps, dans l’auto_response_log qui enregistre chaque action risquée. L’efficacité token se vérifie dans le multi-modèle par tâche, dans les UsageLimits par Step, dans la séparation entre classification cheap et synthèse solide.
Il n’y a pas de magie là-dedans, et c’est précisément le propos. Le Socle fait deux cents lignes, la rule table est un fichier versionné, les garde-fous sont du code défensif, le scheduler est une élection de leader par bail Postgres. Tout cela est de l’ingénierie de plateforme classique et lisible. La valeur n’est pas dans la complexité, elle est dans le refus de la complexité superflue.
La plateforme est extensible par construction. Ajouter un workflow, c’est ajouter un pipeline de Steps, et le Socle ne change pas. Les trois agents actuels partagent la même fondation, et les suivants la partageront aussi, qu’il s’agisse de veille, de statistiques, de tickets ou d’autre chose. C’est la propriété qui rend l’investissement dans le Socle payant : on le paie une fois, et chaque nouveau agent en bénéficie sans coût marginal de plateforme.
D’autres workflows viendront. La v1 pose le cadre, et le cadre est pensé pour grandir sans se réécrire. Si cette approche résonne avec les pipelines que vous avez en production, ou avec ceux que vous hésitez à confier à un agent full-LLM, la voie supervisée mérite qu’on l’examine sérieusement.