From 287bb9174501de52e23e71c3bde088b34a9b247e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 2 Sep 2026 17:42:58 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs(site):=20une=20section=20=C2=AB=20Cas?= =?UTF-8?q?=20d'usage=20=C2=BB,=20et=20deux=20choses=20enti=C3=A8res=20ded?= =?UTF-8?q?ans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les dix-sept sections de documentation/user/ sont de la référence de fonctionnalité : une page par mécanisme. Il n'existait aucun créneau pour « voici une chose entière construite avec Durable » — d'où seize scénarios sous symfony/src/Samples/ mentionnés nulle part, et la maquette Nexus à quatre applications enterrée dans un paragraphe de nexus/. Deux entrées au jour un : la maquette Nexus, et l'agent IA interruptible. Les scénarios de Samples/ restent dehors — ce sont des vignettes de fonctionnalité, pas des applications entières, et les fusionner parce que les deux manquent serait la mauvaise raison. « Cas d'usage » et pas « Advanced use cases » : l'adjectif décrit une difficulté, alors que ce qui distingue ces entrées est la complétude. La maquette Nexus n'est pas difficile, elle est entière. La page de l'agent porte un avertissement : son code n'est pas fusionné, et il n'a pas de chemin de démarrage documenté. Elle publie le motif — les quatre coutures, la classification par effet, la clé d'idempotence dérivée du workflow, l'approbation par signal — qui s'applique à n'importe quel agent Symfony AI. Pas un paquet : symfony/ai est en 0.x, treize mineures, sans promesse de BC. Première section du guide à avoir des sous-pages. Vérifié sur un build --minify servi en HTTP : la nav imbrique, la table des matières est complète, les liens croisés relatifs résolvent, et les deux langues rendent. Le shortcode `hint` du thème est déprécié en 0.165 — les avertissements passent par les alertes Markdown, que le thème habille toujours en `book-hint`. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/user/use-cases/_index.fr.md | 29 ++++ documentation/user/use-cases/_index.md | 29 ++++ .../user/use-cases/durable-agent.fr.md | 134 ++++++++++++++++++ documentation/user/use-cases/durable-agent.md | 128 +++++++++++++++++ documentation/user/use-cases/nexus-demo.fr.md | 100 +++++++++++++ documentation/user/use-cases/nexus-demo.md | 96 +++++++++++++ 6 files changed, 516 insertions(+) create mode 100644 documentation/user/use-cases/_index.fr.md create mode 100644 documentation/user/use-cases/_index.md create mode 100644 documentation/user/use-cases/durable-agent.fr.md create mode 100644 documentation/user/use-cases/durable-agent.md create mode 100644 documentation/user/use-cases/nexus-demo.fr.md create mode 100644 documentation/user/use-cases/nexus-demo.md diff --git a/documentation/user/use-cases/_index.fr.md b/documentation/user/use-cases/_index.fr.md new file mode 100644 index 00000000..dedcf792 --- /dev/null +++ b/documentation/user/use-cases/_index.fr.md @@ -0,0 +1,29 @@ +--- +title: Cas d'usage +weight: 45 +--- + +# Cas d'usage + +Le reste de ce guide est de la référence : une page par fonctionnalité, `await` ici, les signaux +là, Nexus plus loin. Cette section fait l'inverse. Chaque entrée est **une chose entière** — +plusieurs applications, plusieurs mécanismes, un problème qui existe en dehors de Durable — avec +son code dans le dépôt et de quoi la lancer. + +Ce ne sont pas des exercices avancés. Aucune n'est difficile ; elles sont *complètes*. C'est la +seule chose qui les distingue d'un exemple de la section [Écrire un workflow](../workflows/). + +| | | +|---|---| +| [Quatre applications qui s'appellent](nexus-demo/) | trois frameworks, quatre namespaces Temporal, un contrat partagé — et une exécution qui sert une opération pendant qu'elle en appelle une autre | +| [Un agent IA interruptible](durable-agent/) | la boucle d'agent de Symfony AI pilotée depuis du code de workflow : elle survit au redémarrage, et elle attend votre accord avant d'envoyer le courriel | + +## Ce qu'une entrée doit contenir + +Pour que la section reste lisible quand elle grandira, chaque page dit, dans cet ordre : + +1. **le problème**, formulé sans le mot « Durable » ; +2. **ce qui est construit** — les fichiers, où ils sont ; +3. **ce que Durable apporte**, et surtout **ce qu'il n'apporte pas** ; +4. **comment on la lance** ; +5. **ce qui n'est pas prouvé.** Une entrée sans cette partie est une brochure. diff --git a/documentation/user/use-cases/_index.md b/documentation/user/use-cases/_index.md new file mode 100644 index 00000000..3bc43276 --- /dev/null +++ b/documentation/user/use-cases/_index.md @@ -0,0 +1,29 @@ +--- +title: Use cases +weight: 45 +--- + +# Use cases + +The rest of this guide is reference material: one page per feature — `await` here, signals there, +Nexus further on. This section does the opposite. Each entry is **a whole thing** — several +applications, several mechanisms, a problem that exists outside Durable — with its code in the +repository and a way to run it. + +These are not advanced exercises. None of them is hard; they are *complete*. That is the only +thing separating them from an example in [Writing a workflow](../workflows/). + +| | | +|---|---| +| [Four applications calling each other](nexus-demo/) | three frameworks, four Temporal namespaces, one shared contract — and an execution that serves one operation while calling another | +| [An interruptible AI agent](durable-agent/) | Symfony AI's agent loop driven from workflow code: it survives a restart, and it waits for your approval before sending the email | + +## What an entry must contain + +So the section stays readable as it grows, every page says, in this order: + +1. **the problem**, stated without the word "Durable"; +2. **what was built** — the files, and where they live; +3. **what Durable brings**, and above all **what it does not**; +4. **how to run it**; +5. **what is not proven.** An entry without that part is a brochure. diff --git a/documentation/user/use-cases/durable-agent.fr.md b/documentation/user/use-cases/durable-agent.fr.md new file mode 100644 index 00000000..92e8f335 --- /dev/null +++ b/documentation/user/use-cases/durable-agent.fr.md @@ -0,0 +1,134 @@ +--- +title: Un agent IA interruptible +weight: 20 +--- + +# Un agent IA interruptible + +> [!WARNING] +> **Prototype.** Le code décrit ici vit sur une branche qui n'est pas encore fusionnée, et il +> n'y a pas de chemin de démarrage documenté. Cette page publie **le motif**, pas un paquet : les +> quatre décisions ci-dessous s'appliquent à n'importe quel agent Symfony AI, avec ou sans le code +> du dépôt. + +## Le problème + +Un agent qui appelle des outils passe des minutes, parfois des heures, à travailler. Pendant ce +temps il fait des choses qui ne se défont pas : il envoie un courriel, il encaisse un paiement, il +pousse un prix en production. + +Deux besoins se cognent. Le premier : **quelqu'un doit pouvoir dire non** avant l'appel dangereux, +et ce quelqu'un est en réunion — il répondra dans dix minutes, pas dans les 30 secondes d'un timeout +HTTP. Le second : **le processus va redémarrer.** Un déploiement, un OOM kill, une machine qui +tourne. Si l'agent avait passé sept appels d'outil sur neuf, on ne veut pas repayer les sept. + +Le hook `ToolCallRequested::deny()` de Symfony AI répond au premier besoin tant que personne ne +redémarre : il est synchrone et in-process. `maxToolCalls` est un compteur en mémoire. Les deux +disparaissent avec le processus. + +## Ce qui est construit + +La boucle d'agent de Symfony AI, **pilotée depuis du code de workflow**. Une conversation est une +exécution de workflow ; chaque message de l'humain est un signal. Entre deux messages le workflow +n'attend pas : il est suspendu, et ne consomme rien. + +L'agent lui-même n'est pas modifié. On compose un `Provider` normal avec deux implémentations à +nous : + +| Couture | Ce qu'elle devient | +|---|---| +| `ModelClientInterface` | un `await` sur une activité — le seul HTTP de tout l'agent | +| `ToolExecutorInterface` | un `await` par appel d'outil, précédé de la garde | +| `ToolboxInterface` | un simple registre de schémas ; il n'exécute plus rien | + +`Agent::call()` est appelé tel quel depuis le workflow. Il ne sait pas qu'il est rejouable. + +## Les quatre décisions + +C'est la partie réutilisable. Aucune ne demande de dépendance. + +**1. La couture basse est `ModelClientInterface`, pas `PlatformInterface`.** C'est ce qui rend +l'exercice court. `Provider::invoke()` transforme la conversation en tableau plat *avant* d'atteindre +le client, et la réponse brute est du JSON. À cet endroit il n'y a donc **rien à traduire** : ni +`MessageBag`, ni `Content`, ni `Thinking`, ni `Metadata`. Se brancher un cran plus haut, sur +`PlatformInterface`, oblige à sérialiser tout l'arbre d'objets — pour le même résultat. + +**2. Ce qui protège, c'est de classer les outils — pas d'avoir des modes.** Chaque outil porte un +`effect` : `read`, `write` ou `external`. Le mode courant ne fait que consulter cette table. Dire +« pousser un prix est `external`, pas `write` » est l'acte de design ; le mode n'est que sa +conséquence. Le défaut est prudent — un outil non classé compte pour `external` — mais ce n'est pas +une excuse pour ne pas classer. + +**Et la plupart des outils ne méritent rien.** Un outil a besoin d'une sécurité d'exécution s'il +répond oui à au moins une de ces questions : + +1. le rejouer deux fois fait-il du mal ? (facturer deux fois, envoyer deux courriels) +2. peut-il réussir alors qu'une étape suivante échouera ? — il lui faut une compensation +3. dure-t-il plus qu'une requête HTTP ? — minutes, heures, jours +4. quelqu'un doit-il l'autoriser ? + +Quatre non — et c'est le cas de `chercher_produit`, `lire_stock`, `consulter_facture` — une activité +suffit. Tout envelopper fabrique le problème qu'on prétend résoudre. + +**3. La clé d'idempotence vient du workflow, pas de l'outil.** Elle doit être déterministe au rejeu, +donc dérivée de l'identifiant d'exécution et de l'identifiant d'appel. Un outil qui fabrique sa +propre clé avec `uniqid()` casse le rejeu au premier redémarrage — et c'est le genre de panne qu'on +découvre en production. + +**4. L'approbation est un signal, avec une échéance d'humain.** Pas un `deny()` synchrone. Et +l'échéance est celle de quelqu'un qui lit, réfléchit et change de fenêtre : le prototype est réglé à +quinze minutes. Il a d'abord été réglé à 120 secondes, et la carte de validation disparaissait sous +les yeux de la personne qui la lisait — l'agent répondait « refusé faute de validation » sans que +personne n'ait rien refusé. + +## Ce que Durable apporte + +- **Une approbation humaine qui survit au redémarrage.** Un workflow qui attend trois jours un + signal d'accord est une autre classe de chose qu'un hook in-process. +- **Une compensation saga sur les outils non idempotents.** L'agent qui a envoyé le courriel puis a + planté a besoin de sa jambe de retour. +- **Des bornes journalisées.** Cap d'itérations et budget de coût dans l'état du workflow survivent + au crash ; un compteur en mémoire non. + +## Ce qu'il n'apporte pas + +- **Pas les réessais.** C'est la table stakes, et `symfony/ai-failover-platform` en couvre déjà une + part. Attention même au piège inverse : Durable qui retente une activité qui, dedans, a déjà + basculé sur trois fournisseurs, ce sont 3×N appels payants. +- **Pas la fiabilité.** L'exécution durable rend un agent faux **fiablement faux**, et rend une + boucle infinie **infiniment durable**. Résilience aux pannes et fiabilité sont deux choses ; la + seconde demande des évaluations, des garde-fous de sortie et des bornes, dont rien n'est du + ressort de Durable. +- **Pas le streaming.** Une activité rend une valeur une fois. Journaliser le résultat assemblé, + streamer sur un canal latéral. + +## Ce que le rejeu a mesuré + +Le test unitaire tourne sur le runner en mémoire en mode distribué : chaque `await` suspend le fiber +et **rejoue le code du workflow depuis le début**. Aucune simulation de crash n'est nécessaire — le +rejeu est le régime normal. + +Sur un scénario à 3 appels modèle et 2 appels d'outil : **6 réexécutions** du code de workflow, et +pourtant l'activité d'appel modèle s'exécute **exactement 3 fois**, celle d'appel d'outil +**exactement 2 fois**. Le journal court-circuite le rejeu ; rien n'est repayé. + +Et les charges sortantes sont **identiques entre deux exécutions indépendantes** — vérifié par +mutation : un `uniqid()` glissé dans le prompt fait rougir l'assertion. C'est ce qui rend le rejeu +sûr, et c'est fragile : le prompt système et la liste d'outils doivent être **journalisés**, pas +relus depuis la configuration au rejeu. Ajouter un outil change sinon le prompt rejoué. + +## Ce qui n'est pas prouvé + +- **Aucun vrai fournisseur.** Le convertisseur de réponses est écrit à la main sur la forme « chat + completions ». Brancher un `symfony/ai-*-platform` le remplacerait sans toucher au reste — mais ce + n'est pas fait. +- **Aucun crash inter-processus.** Le test tourne en mémoire. Le runner y rejoue pour de vrai, mais + un vrai redémarrage de processus reste à démontrer. +- **Les blocs `Thinking` n'ont jamais traversé la frontière.** Le raisonnement tient — le JSON brut + transporte ce qu'il contient — mais le convertisseur écrit à la main ne lit que `content` et + `tool_calls`, et les laisserait tomber. +- **La classification d'échec.** Une activité d'outil qui échoue tue aujourd'hui l'appel d'agent. + C'est un défaut, pas une décision. +- **Le socle bouge.** `symfony/ai` est en 0.x, treize versions mineures à ce jour, sans promesse de + compatibilité. Les quatre coutures utilisées sont des interfaces publiques, mais rien ne garantit + leur forme à la mineure suivante. C'est la raison pour laquelle ceci est un motif et non un paquet. diff --git a/documentation/user/use-cases/durable-agent.md b/documentation/user/use-cases/durable-agent.md new file mode 100644 index 00000000..4613e707 --- /dev/null +++ b/documentation/user/use-cases/durable-agent.md @@ -0,0 +1,128 @@ +--- +title: An interruptible AI agent +weight: 20 +--- + +# An interruptible AI agent + +> [!WARNING] +> **Prototype.** The code described here lives on a branch that has not been merged, and there is +> no documented way to start it yet. This page publishes **the pattern**, not a package: the four +> decisions below apply to any Symfony AI agent, with or without the repository's code. + +## The problem + +An agent that calls tools spends minutes, sometimes hours, working. Meanwhile it does things that do +not undo: it sends an email, it charges a payment, it pushes a price to production. + +Two needs collide. First: **someone has to be able to say no** before the dangerous call, and that +someone is in a meeting — they will answer in ten minutes, not within an HTTP timeout's thirty +seconds. Second: **the process is going to restart.** A deploy, an OOM kill, a machine rotating out. +If the agent was seven tool calls into nine, you do not want to pay for the seven again. + +Symfony AI's `ToolCallRequested::deny()` hook answers the first need as long as nobody restarts: it +is synchronous and in-process. `maxToolCalls` is an in-memory counter. Both vanish with the process. + +## What was built + +Symfony AI's agent loop, **driven from workflow code**. A conversation is a workflow execution; +every human message is a signal. Between two messages the workflow is not waiting — it is suspended, +consuming nothing. + +The agent itself is unmodified. You compose an ordinary `Provider` with two implementations of your +own: + +| Seam | What it becomes | +|---|---| +| `ModelClientInterface` | an `await` on an activity — the only HTTP in the whole agent | +| `ToolExecutorInterface` | one `await` per tool call, preceded by the guard | +| `ToolboxInterface` | a plain schema registry; it no longer executes anything | + +`Agent::call()` is called as-is from the workflow. It does not know it is replayable. + +## The four decisions + +This is the reusable part. None of it requires a dependency. + +**1. The low seam is `ModelClientInterface`, not `PlatformInterface`.** This is what makes the +exercise short. `Provider::invoke()` turns the conversation into a flat array *before* it reaches +the client, and the raw response is JSON. At that point there is **nothing to translate**: no +`MessageBag`, no `Content`, no `Thinking`, no `Metadata`. Hooking one level higher, at +`PlatformInterface`, forces you to serialize the whole object tree — for the same result. + +**2. What protects you is classifying tools, not having modes.** Every tool carries an `effect`: +`read`, `write` or `external`. The current mode only consults that table. Saying "pushing a price is +`external`, not `write`" is the design act; the mode is merely its consequence. The default is +cautious — an unclassified tool counts as `external` — but that is no excuse for not classifying. + +**And most tools deserve nothing.** A tool needs execution safety if it answers yes to at least one +of these: + +1. does replaying it twice do harm? (charging twice, sending two emails) +2. can it succeed while a later step fails? — then it needs compensation +3. does it last longer than an HTTP request? — minutes, hours, days +4. does someone have to authorize it? + +Four noes — and that is the case for `search_product`, `read_stock`, `get_invoice` — and a plain +activity is enough. Wrapping everything manufactures the problem you claim to be solving. + +**3. The idempotency key comes from the workflow, not the tool.** It has to be deterministic on +replay, so it is derived from the execution id and the call id. A tool that builds its own key with +`uniqid()` breaks replay on the first restart — and that is the kind of failure you discover in +production. + +**4. Approval is a signal, with a human's deadline.** Not a synchronous `deny()`. And the deadline is +that of someone who reads, thinks and switches windows: the prototype is set to fifteen minutes. It +was first set to 120 seconds, and the approval card vanished under the eyes of the person reading it +— the agent answered "denied, no approval" without anyone having denied anything. + +## What Durable brings + +- **Human approval that survives a restart.** A workflow waiting three days for an approval signal + is a different class of thing from an in-process hook. +- **Saga compensation on non-idempotent tools.** The agent that sent the email and then crashed + needs its return leg. +- **Journaled bounds.** An iteration cap and a cost budget held in workflow state survive a crash; + an in-memory counter does not. + +## What it does not bring + +- **Not retries.** Those are table stakes, and `symfony/ai-failover-platform` already covers part of + it. Watch the inverse trap too: Durable retrying an activity that has itself already failed over + across three providers is 3×N billable calls. +- **Not reliability.** Durable execution makes a wrong agent **reliably wrong**, and makes an + infinite loop **infinitely durable**. Failure resilience and reliability are two different things; + the second needs evals, exit guardrails and bounds, none of which is Durable's business. +- **Not streaming.** An activity returns a value once. Journal the assembled result, stream on a + side channel. + +## What replay measured + +The unit test runs on the in-memory runner in distributed mode: every `await` suspends the fiber and +**replays the workflow code from the top**. No crash simulation is needed — replay is the normal +regime. + +On a scenario with 3 model calls and 2 tool calls: **6 re-executions** of the workflow code, and yet +the model-invocation activity runs **exactly 3 times** and the tool-call activity **exactly twice**. +The journal short-circuits replay; nothing is paid for twice. + +And outbound payloads are **byte-identical across two independent executions** — verified by +mutation: a `uniqid()` slipped into the prompt turns the assertion red. That is what makes replay +safe, and it is fragile: the system prompt and the tool list must be **journaled**, not re-read from +configuration on replay. Otherwise adding a tool changes the replayed prompt. + +## What is not proven + +- **No real provider.** The response converter is hand-written against the "chat completions" shape. + Plugging in a `symfony/ai-*-platform` would replace it without touching anything else — but that + has not been done. +- **No cross-process crash.** The test runs in memory. The runner replays for real there, but an + actual process restart remains to be demonstrated. +- **`Thinking` blocks have never crossed the boundary.** The reasoning holds — raw JSON carries what + it contains — but the hand-written converter only reads `content` and `tool_calls`, and would drop + them. +- **Failure classification.** A failing tool activity currently kills the agent call. That is a + default, not a decision. +- **The ground moves.** `symfony/ai` is 0.x, thirteen minor versions so far, with no compatibility + promise. The four seams used are public interfaces, but nothing guarantees their shape at the next + minor. That is why this is a pattern and not a package. diff --git a/documentation/user/use-cases/nexus-demo.fr.md b/documentation/user/use-cases/nexus-demo.fr.md new file mode 100644 index 00000000..9068c492 --- /dev/null +++ b/documentation/user/use-cases/nexus-demo.fr.md @@ -0,0 +1,100 @@ +--- +title: Quatre applications qui s'appellent +weight: 10 +--- + +# Quatre applications qui s'appellent + +## Le problème + +Une commande traverse quatre systèmes qui n'appartiennent pas à la même équipe : la boutique retient +le stock, le métier facture, la logistique planifie et expédie, l'ERP suit. Chacun a son dépôt, son +framework, son rythme de déploiement. Aucun n'a envie d'importer le code d'un autre. + +La façon habituelle de coudre ça — une API HTTP par service, un client par appelant, un retry par +client, un timeout par retry — marche jusqu'au jour où l'un des quatre est éteint pendant la +transaction. Alors quelqu'un doit décider si on attend, si on rejoue, et ce qu'il advient de ce qui +a déjà été pris. + +## Ce qui est construit + +Quatre applications, quatre namespaces Temporal, trois frameworks. Elles vivent dans le dépôt, sous +[`sylius/`](https://github.com/gplanchat/durable-dev/tree/main/sylius), +[`symfony/`](https://github.com/gplanchat/durable-dev/tree/main/symfony), +[`magento/`](https://github.com/gplanchat/durable-dev/tree/main/magento) et +[`laravel/`](https://github.com/gplanchat/durable-dev/tree/main/laravel). + +| | la boutique | le métier | le banc Magento | la logistique | +|---|---|---|---|---| +| framework | Sylius | Symfony | Mage-OS | Laravel | +| sert | `stock` | `facturation` | — | `livraison` | +| appelle | `facturation` | `stock` | les trois | `stock`, **depuis le workflow qui sert** | +| PHP | 8.3 | 8.3 | 8.2 | 8.2 | + +Les quatre lisent le même paquet de contrats, `src/DurableDemoContracts/`. **Rien d'autre ne circule +entre elles** : pas de client HTTP, pas de SDK partagé, pas de classe d'implémentation. + +## Ce que Durable apporte + +**Appeler ne demande rien.** `WorkflowEnvironment::nexusStub()` lit le contrat par réflexion. +Servir se câble une fois par hôte — et se câble *hors* de Symfony : la logistique enregistre ses +gestionnaires avec deux classes et six lignes de `config/durable.php`, le banc Magento câble en +`di.xml`. La moitié servante de Nexus n'est pas une fonctionnalité du bundle. + +**Les deux formes s'écrivent pareil.** `CommandeWorkflow` appelle `verifier` puis `encaisser` sur le +même stub. La première revient en quelques millisecondes, servie par une méthode ordinaire ; la +seconde prend une quinzaine de secondes, remplie par un workflow d'en face. **Le code de l'appelant +ne distingue pas les deux**, et c'est tout le sujet. + +**L'attente ne tient rien d'ouvert.** Pendant une mise au point, le worker qui devait faire avancer +l'encaissement est resté éteint quatre minutes. L'opération est restée en +`NEXUS_OPERATION_STARTED`, l'appelant n'a rien consommé, et tout s'est terminé normalement quand le +worker est revenu. Aucune connexion, aucun processus, aucune transaction n'attendait. Refait depuis +Magento : 49 secondes, même résultat. + +## Ce qu'il n'apporte pas + +**Pas la compensation.** Aucun des trois contrats n'a d'opération qui rende ce qu'il a pris. La +seule protection est **l'ordre des appels** : `CommandeNexusWorkflow` demande d'abord tout ce qui +peut dire non — vérifier la facture, planifier la tournée, retenir le stock — et n'engage +qu'ensuite. Les deux ordres inverses ont été écrits d'abord et mesurés : une commande en USD +retenait le stock avant de se faire refuser la facture, et une commande de six colis était +**encaissée** avant que la logistique ne refuse de la porter. + +**Pas l'idempotence.** Une tâche Nexus est redélivrée ; c'est le gestionnaire qui doit tenir. Celui +de `stock` écrit son verdict dans `app_durable_stock_reservation`, clé par identifiant de commande — +rejouer la même commande rend le même verdict et ne retient pas de stock une seconde fois. Ça a +été écrit à la main, Durable ne l'a pas fourni. + +## Comment on la lance + +```bash +demo/lancer.sh # démarre les huit workers +demo/lancer.sh --etat # dit qui tourne +demo/lancer.sh --arreter # les arrête +``` + +L'ordre de démarrage n'a pas d'importance : un worker en retard fait attendre, il ne fait pas +échouer. + +Deux prérequis qui ne se devinent pas, et que +[`demo/README.md`](https://github.com/gplanchat/durable-dev/blob/main/demo/README.md) détaille : + +- **un serveur Temporal dont les API Nexus sont actives.** `temporal server start-dev` convient ; + `temporalio/auto-setup:1.25.2` répond `Nexus APIs are disabled` à la création d'endpoint ; +- **deux binaires PHP.** 8.3 pour les deux maquettes Symfony, 8.2 pour Magento et Laravel — c'est + mesuré, pas frileux : sur le poste de référence aucune version unique n'a l'intersection des + extensions exigées. + +## Ce qui n'est pas prouvé + +- **La montée en charge.** Quatre applications sur un poste, un serveur `start-dev`, une commande à + la fois. Rien ici ne dit ce que fait une file Nexus sous charge réelle. +- **La reprise après un échec du gestionnaire servant.** Ce qui a été mesuré, c'est un worker + *éteint* — pas un gestionnaire qui lève au milieu de son travail. +- **La sécurité.** Les quatre namespaces sont sur le même serveur sans mTLS ni autorisation. Le + cloisonnement inter-équipes, qui est la moitié de l'argument Nexus, n'est pas démontré. + +Le détail de ce que chaque maquette a ajouté, maquette par maquette, est dans +[`demo/README.md`](https://github.com/gplanchat/durable-dev/blob/main/demo/README.md). La mécanique +Nexus elle-même est décrite dans [Opérations Nexus](../../nexus/). diff --git a/documentation/user/use-cases/nexus-demo.md b/documentation/user/use-cases/nexus-demo.md new file mode 100644 index 00000000..fa8833ee --- /dev/null +++ b/documentation/user/use-cases/nexus-demo.md @@ -0,0 +1,96 @@ +--- +title: Four applications calling each other +weight: 10 +--- + +# Four applications calling each other + +## The problem + +An order crosses four systems owned by four different teams: the shop holds stock, the business +side invoices, logistics plans and ships, the ERP follows. Each has its own repository, framework +and release cadence. None of them wants to import another one's code. + +The usual way to stitch that together — one HTTP API per service, one client per caller, one retry +per client, one timeout per retry — works until one of the four is down mid-transaction. Then +someone has to decide whether to wait, whether to replay, and what happens to what was already +taken. + +## What was built + +Four applications, four Temporal namespaces, three frameworks. They live in the repository under +[`sylius/`](https://github.com/gplanchat/durable-dev/tree/main/sylius), +[`symfony/`](https://github.com/gplanchat/durable-dev/tree/main/symfony), +[`magento/`](https://github.com/gplanchat/durable-dev/tree/main/magento) and +[`laravel/`](https://github.com/gplanchat/durable-dev/tree/main/laravel). + +| | the shop | the business side | the Magento bench | logistics | +|---|---|---|---|---| +| framework | Sylius | Symfony | Mage-OS | Laravel | +| serves | `stock` | `facturation` | — | `livraison` | +| calls | `facturation` | `stock` | all three | `stock`, **from the workflow that serves** | +| PHP | 8.3 | 8.3 | 8.2 | 8.2 | + +All four read the same contract package, `src/DurableDemoContracts/`. **Nothing else travels between +them**: no HTTP client, no shared SDK, no implementation class. + +## What Durable brings + +**Calling requires nothing.** `WorkflowEnvironment::nexusStub()` reads the contract by reflection. +Serving is wired once per host — and it wires up *outside* Symfony: logistics registers its handlers +with two classes and six lines of `config/durable.php`, the Magento bench wires in `di.xml`. The +serving half of Nexus is not a bundle feature. + +**Both shapes are written the same way.** `CommandeWorkflow` calls `verifier`, then `encaisser`, on +the same stub. The first returns in milliseconds, served by an ordinary method; the second takes +about fifteen seconds, fulfilled by a workflow on the other side. **The caller's code does not tell +them apart**, and that is the whole point. + +**Waiting holds nothing open.** During a debugging session the worker that was to advance the +payment stayed down for four minutes. The operation stayed in `NEXUS_OPERATION_STARTED`, the caller +consumed nothing, and everything completed normally when the worker came back. No connection, no +process, no transaction was waiting. Repeated from Magento: 49 seconds, same result. + +## What it does not bring + +**Not compensation.** None of the three contracts has an operation that gives back what it took. The +only protection is **call ordering**: `CommandeNexusWorkflow` first asks everything that can say no +— check the invoice, plan the round, hold the stock — and only then commits. Both reverse orders +were written first, and measured: a USD order held stock before being refused an invoice, and a +six-parcel order was **charged** before logistics refused to carry it. + +**Not idempotency.** A Nexus task gets redelivered; the handler has to hold. The `stock` handler +writes its verdict to `app_durable_stock_reservation`, keyed by order id — replaying the same order +returns the same verdict and does not hold stock twice. That was written by hand; Durable did not +provide it. + +## How to run it + +```bash +demo/lancer.sh # start the eight workers +demo/lancer.sh --etat # report who is running +demo/lancer.sh --arreter # stop them +``` + +Start order does not matter: a late worker makes things wait, it does not make them fail. + +Two prerequisites you would not guess, detailed in +[`demo/README.md`](https://github.com/gplanchat/durable-dev/blob/main/demo/README.md): + +- **a Temporal server with the Nexus APIs enabled.** `temporal server start-dev` will do; + `temporalio/auto-setup:1.25.2` answers `Nexus APIs are disabled` on endpoint creation; +- **two PHP binaries.** 8.3 for the two Symfony apps, 8.2 for Magento and Laravel — measured, not + timid: on the reference machine no single version has the intersection of required extensions. + +## What is not proven + +- **Scale.** Four applications on one machine, a `start-dev` server, one order at a time. Nothing + here says what a Nexus queue does under real load. +- **Recovery from a failing serving handler.** What was measured is a worker that was *down* — not a + handler that throws halfway through its work. +- **Security.** The four namespaces sit on the same server with no mTLS and no authorization. + Cross-team isolation, which is half the Nexus argument, is not demonstrated. + +What each app added, one by one, is in +[`demo/README.md`](https://github.com/gplanchat/durable-dev/blob/main/demo/README.md). The Nexus +mechanics themselves are described in [Nexus operations](../../nexus/). From 39e20e2610d66929e7e319107f9cd81ed29d3d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 2 Sep 2026 18:23:47 +0200 Subject: [PATCH 2/4] docs(use-cases): la puce Thinking dit ce qui passe, et ce qui reste dehors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ancienne version disait « le convertisseur les laisserait tomber » et s'arrêtait là. Elle sous-estimait le sujet sur trois points, tous vérifiés depuis dans le code de symfony/ai v0.13.0 : - la perte est **silencieuse** — pas d'exception, un tour d'après amputé ; - le `signature` que porte `Thinking` existe, dit son docblock, pour vérifier les blocs « when they are replayed on a subsequent turn » : chez un fournisseur qui l'exige, l'appel échoue au deuxième tour ; - combler le trou est un **point de changement**, pas un correctif — le journal garde le même JSON, le nouveau convertisseur en extrait davantage, et la charge du tour suivant cesse de correspondre à celle qui avait été envoyée. Le code du prototype le fait maintenant traverser (branche spike). Reste dehors : la signature, qu'aucun normaliseur de `ai-platform` n'écrit sur le fil. Co-Authored-By: Claude Opus 5 (1M context) --- .../user/use-cases/durable-agent.fr.md | 29 +++++++++++++++++-- documentation/user/use-cases/durable-agent.md | 27 +++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/documentation/user/use-cases/durable-agent.fr.md b/documentation/user/use-cases/durable-agent.fr.md index 92e8f335..ac6a3699 100644 --- a/documentation/user/use-cases/durable-agent.fr.md +++ b/documentation/user/use-cases/durable-agent.fr.md @@ -124,9 +124,32 @@ relus depuis la configuration au rejeu. Ajouter un outil change sinon le prompt n'est pas fait. - **Aucun crash inter-processus.** Le test tourne en mémoire. Le runner y rejoue pour de vrai, mais un vrai redémarrage de processus reste à démontrer. -- **Les blocs `Thinking` n'ont jamais traversé la frontière.** Le raisonnement tient — le JSON brut - transporte ce qu'il contient — mais le convertisseur écrit à la main ne lit que `content` et - `tool_calls`, et les laisserait tomber. +- **Les blocs de raisonnement passent, mais la signature reste dehors.** Le convertisseur ne lisait + d'abord que `content` et `tool_calls` : un `reasoning_content` journalisé était perdu — sans + exception et sans trace, le tour d'après partait simplement amputé. Il lit maintenant le champ et + rend un `MultiPartResult` ; `Message::toContent()` le déplie en `Thinking`, et + `AssistantMessageNormalizer` le remet sur le fil au tour suivant. Deux tests le tiennent, vérifiés + par mutation. + + Ce qui reste dehors, c'est la **signature** — le champ dont le docblock de `Thinking` dit qu'il + sert « to verify thinking blocks when they are replayed on a subsequent turn ». Aucun normaliseur + de `ai-platform` ne l'écrit : `AssistantMessageNormalizer` concatène le contenu dans + `reasoning_content` et ne lit jamais `getSignature()`. Le rejeu signé suppose donc un bridge + fournisseur qui remplace ce normaliseur — plausible, c'est à ça que sert le `Contract`, mais + invérifiable ici : aucun `ai-*-platform` n'est installé. + + **La leçon vaut au-delà du raisonnement.** Ce correctif n'en était pas un : c'est un **point de + changement**. Tant que le convertisseur laissait tomber le champ, il le laissait tomber *de la + même façon à chaque rejeu* — déterministe, donc sûr. Le jour où on le corrige, le journal contient + toujours le même JSON mais le convertisseur en extrait davantage : le message reconstruit porte un + champ de plus, et la charge du tour N+1 ne ressemble plus à celle qui avait été envoyée. Toutes les + exécutions en vol divergent. Ici c'est sans conséquence — un prototype, rien en vol. En production + ce genre de correction se déclare et se garde ; elle ne se glisse pas dans un patch. + + La contrepartie est bonne, et elle vient d'un choix : journaliser la réponse **brute** plutôt qu'un + DTO converti. Le raisonnement était déjà dans le journal de toutes les exécutions passées, avant + même que quelque chose le lise. Le journal transporte des champs que le convertisseur ne connaît + pas encore — c'est ce qui a rendu la correction gratuite. - **La classification d'échec.** Une activité d'outil qui échoue tue aujourd'hui l'appel d'agent. C'est un défaut, pas une décision. - **Le socle bouge.** `symfony/ai` est en 0.x, treize versions mineures à ce jour, sans promesse de diff --git a/documentation/user/use-cases/durable-agent.md b/documentation/user/use-cases/durable-agent.md index 4613e707..8c336442 100644 --- a/documentation/user/use-cases/durable-agent.md +++ b/documentation/user/use-cases/durable-agent.md @@ -118,9 +118,30 @@ configuration on replay. Otherwise adding a tool changes the replayed prompt. has not been done. - **No cross-process crash.** The test runs in memory. The runner replays for real there, but an actual process restart remains to be demonstrated. -- **`Thinking` blocks have never crossed the boundary.** The reasoning holds — raw JSON carries what - it contains — but the hand-written converter only reads `content` and `tool_calls`, and would drop - them. +- **Reasoning blocks now travel; the signature does not.** The converter first read only `content` + and `tool_calls`, so a journaled `reasoning_content` was lost — no exception, no trace, just an + amputated following turn. It now reads the field and returns a `MultiPartResult`; + `Message::toContent()` unrolls it into a `Thinking`, and `AssistantMessageNormalizer` puts it back + on the wire on the next turn. Two tests hold it, both verified by mutation. + + What stays out is the **signature** — the field whose docblock on `Thinking` says it serves "to + verify thinking blocks when they are replayed on a subsequent turn". No normalizer in + `ai-platform` writes it: `AssistantMessageNormalizer` concatenates the content into + `reasoning_content` and never reads `getSignature()`. Signed replay therefore assumes a provider + bridge replacing that normalizer — plausible, that is what `Contract` is for, but unverifiable + here: no `ai-*-platform` is installed. + + **The lesson outlives the reasoning field.** This was not a bug fix, it was a **change point**. As + long as the converter dropped the field, it dropped it *the same way on every replay* — + deterministic, therefore safe. The day it is fixed, the journal still holds the same JSON but the + converter extracts more from it: the reconstructed message carries one more field, and the payload + for turn N+1 no longer matches what was originally sent. Every in-flight execution diverges. Here + that costs nothing — a prototype, nothing in flight. In production this kind of fix gets declared + and guarded; it does not slip into a patch. + + The upside came from one choice: journaling the **raw** response rather than a converted DTO. The + reasoning was already in the journal of every past execution, before anything read it. The journal + carries fields the converter does not know about yet — that is what made the fix free. - **Failure classification.** A failing tool activity currently kills the agent call. That is a default, not a decision. - **The ground moves.** `symfony/ai` is 0.x, thirteen minor versions so far, with no compatibility From 761ea768ea31128cd83ffa1a495f82b3d58c27d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Wed, 2 Sep 2026 19:05:07 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(use-cases):=20retirer=20l'entr=C3=A9e?= =?UTF-8?q?=20de=20l'agent,=20son=20prototype=20n'est=20pas=20pos=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page décrivait un convertisseur écrit à la main et un pont fournisseur absent. Les deux sont faux depuis que le spike a fusionné : le convertisseur est supprimé, le pont Mistral est installé — et il refuse par un 422 le `reasoning_content` que la page présentait comme la forme du raisonnement. Elle n'avait de toute façon pas de quoi la lancer, ce que son propre avertissement disait. Une entrée qui renvoie vers quelque chose qu'on ne peut pas démarrer n'est pas une entrée ; c'est la règle que le `_index` de la section pose lui-même. La section tient avec `nexus-demo`, qui est exact et qui démarre. L'entrée de l'agent reviendra avec son chemin de lancement. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/user/use-cases/_index.fr.md | 5 +- documentation/user/use-cases/_index.md | 5 +- .../user/use-cases/durable-agent.fr.md | 157 ------------------ documentation/user/use-cases/durable-agent.md | 149 ----------------- 4 files changed, 8 insertions(+), 308 deletions(-) delete mode 100644 documentation/user/use-cases/durable-agent.fr.md delete mode 100644 documentation/user/use-cases/durable-agent.md diff --git a/documentation/user/use-cases/_index.fr.md b/documentation/user/use-cases/_index.fr.md index dedcf792..126c9da9 100644 --- a/documentation/user/use-cases/_index.fr.md +++ b/documentation/user/use-cases/_index.fr.md @@ -16,7 +16,10 @@ seule chose qui les distingue d'un exemple de la section [Écrire un workflow](. | | | |---|---| | [Quatre applications qui s'appellent](nexus-demo/) | trois frameworks, quatre namespaces Temporal, un contrat partagé — et une exécution qui sert une opération pendant qu'elle en appelle une autre | -| [Un agent IA interruptible](durable-agent/) | la boucle d'agent de Symfony AI pilotée depuis du code de workflow : elle survit au redémarrage, et elle attend votre accord avant d'envoyer le courriel | + +Une seconde entrée — un agent IA interruptible, dont la boucle est pilotée depuis du code de +workflow — attend que son prototype se pose. Elle arrivera avec de quoi la lancer : une entrée qui +renvoie vers quelque chose qu'on ne peut pas démarrer n'est pas une entrée. ## Ce qu'une entrée doit contenir diff --git a/documentation/user/use-cases/_index.md b/documentation/user/use-cases/_index.md index 3bc43276..3586932f 100644 --- a/documentation/user/use-cases/_index.md +++ b/documentation/user/use-cases/_index.md @@ -16,7 +16,10 @@ thing separating them from an example in [Writing a workflow](../workflows/). | | | |---|---| | [Four applications calling each other](nexus-demo/) | three frameworks, four Temporal namespaces, one shared contract — and an execution that serves one operation while calling another | -| [An interruptible AI agent](durable-agent/) | Symfony AI's agent loop driven from workflow code: it survives a restart, and it waits for your approval before sending the email | + +A second entry — an interruptible AI agent, its loop driven from workflow code — is waiting for its +prototype to settle. It will land with a way to run it: an entry pointing at something you cannot +start is not an entry. ## What an entry must contain diff --git a/documentation/user/use-cases/durable-agent.fr.md b/documentation/user/use-cases/durable-agent.fr.md deleted file mode 100644 index ac6a3699..00000000 --- a/documentation/user/use-cases/durable-agent.fr.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Un agent IA interruptible -weight: 20 ---- - -# Un agent IA interruptible - -> [!WARNING] -> **Prototype.** Le code décrit ici vit sur une branche qui n'est pas encore fusionnée, et il -> n'y a pas de chemin de démarrage documenté. Cette page publie **le motif**, pas un paquet : les -> quatre décisions ci-dessous s'appliquent à n'importe quel agent Symfony AI, avec ou sans le code -> du dépôt. - -## Le problème - -Un agent qui appelle des outils passe des minutes, parfois des heures, à travailler. Pendant ce -temps il fait des choses qui ne se défont pas : il envoie un courriel, il encaisse un paiement, il -pousse un prix en production. - -Deux besoins se cognent. Le premier : **quelqu'un doit pouvoir dire non** avant l'appel dangereux, -et ce quelqu'un est en réunion — il répondra dans dix minutes, pas dans les 30 secondes d'un timeout -HTTP. Le second : **le processus va redémarrer.** Un déploiement, un OOM kill, une machine qui -tourne. Si l'agent avait passé sept appels d'outil sur neuf, on ne veut pas repayer les sept. - -Le hook `ToolCallRequested::deny()` de Symfony AI répond au premier besoin tant que personne ne -redémarre : il est synchrone et in-process. `maxToolCalls` est un compteur en mémoire. Les deux -disparaissent avec le processus. - -## Ce qui est construit - -La boucle d'agent de Symfony AI, **pilotée depuis du code de workflow**. Une conversation est une -exécution de workflow ; chaque message de l'humain est un signal. Entre deux messages le workflow -n'attend pas : il est suspendu, et ne consomme rien. - -L'agent lui-même n'est pas modifié. On compose un `Provider` normal avec deux implémentations à -nous : - -| Couture | Ce qu'elle devient | -|---|---| -| `ModelClientInterface` | un `await` sur une activité — le seul HTTP de tout l'agent | -| `ToolExecutorInterface` | un `await` par appel d'outil, précédé de la garde | -| `ToolboxInterface` | un simple registre de schémas ; il n'exécute plus rien | - -`Agent::call()` est appelé tel quel depuis le workflow. Il ne sait pas qu'il est rejouable. - -## Les quatre décisions - -C'est la partie réutilisable. Aucune ne demande de dépendance. - -**1. La couture basse est `ModelClientInterface`, pas `PlatformInterface`.** C'est ce qui rend -l'exercice court. `Provider::invoke()` transforme la conversation en tableau plat *avant* d'atteindre -le client, et la réponse brute est du JSON. À cet endroit il n'y a donc **rien à traduire** : ni -`MessageBag`, ni `Content`, ni `Thinking`, ni `Metadata`. Se brancher un cran plus haut, sur -`PlatformInterface`, oblige à sérialiser tout l'arbre d'objets — pour le même résultat. - -**2. Ce qui protège, c'est de classer les outils — pas d'avoir des modes.** Chaque outil porte un -`effect` : `read`, `write` ou `external`. Le mode courant ne fait que consulter cette table. Dire -« pousser un prix est `external`, pas `write` » est l'acte de design ; le mode n'est que sa -conséquence. Le défaut est prudent — un outil non classé compte pour `external` — mais ce n'est pas -une excuse pour ne pas classer. - -**Et la plupart des outils ne méritent rien.** Un outil a besoin d'une sécurité d'exécution s'il -répond oui à au moins une de ces questions : - -1. le rejouer deux fois fait-il du mal ? (facturer deux fois, envoyer deux courriels) -2. peut-il réussir alors qu'une étape suivante échouera ? — il lui faut une compensation -3. dure-t-il plus qu'une requête HTTP ? — minutes, heures, jours -4. quelqu'un doit-il l'autoriser ? - -Quatre non — et c'est le cas de `chercher_produit`, `lire_stock`, `consulter_facture` — une activité -suffit. Tout envelopper fabrique le problème qu'on prétend résoudre. - -**3. La clé d'idempotence vient du workflow, pas de l'outil.** Elle doit être déterministe au rejeu, -donc dérivée de l'identifiant d'exécution et de l'identifiant d'appel. Un outil qui fabrique sa -propre clé avec `uniqid()` casse le rejeu au premier redémarrage — et c'est le genre de panne qu'on -découvre en production. - -**4. L'approbation est un signal, avec une échéance d'humain.** Pas un `deny()` synchrone. Et -l'échéance est celle de quelqu'un qui lit, réfléchit et change de fenêtre : le prototype est réglé à -quinze minutes. Il a d'abord été réglé à 120 secondes, et la carte de validation disparaissait sous -les yeux de la personne qui la lisait — l'agent répondait « refusé faute de validation » sans que -personne n'ait rien refusé. - -## Ce que Durable apporte - -- **Une approbation humaine qui survit au redémarrage.** Un workflow qui attend trois jours un - signal d'accord est une autre classe de chose qu'un hook in-process. -- **Une compensation saga sur les outils non idempotents.** L'agent qui a envoyé le courriel puis a - planté a besoin de sa jambe de retour. -- **Des bornes journalisées.** Cap d'itérations et budget de coût dans l'état du workflow survivent - au crash ; un compteur en mémoire non. - -## Ce qu'il n'apporte pas - -- **Pas les réessais.** C'est la table stakes, et `symfony/ai-failover-platform` en couvre déjà une - part. Attention même au piège inverse : Durable qui retente une activité qui, dedans, a déjà - basculé sur trois fournisseurs, ce sont 3×N appels payants. -- **Pas la fiabilité.** L'exécution durable rend un agent faux **fiablement faux**, et rend une - boucle infinie **infiniment durable**. Résilience aux pannes et fiabilité sont deux choses ; la - seconde demande des évaluations, des garde-fous de sortie et des bornes, dont rien n'est du - ressort de Durable. -- **Pas le streaming.** Une activité rend une valeur une fois. Journaliser le résultat assemblé, - streamer sur un canal latéral. - -## Ce que le rejeu a mesuré - -Le test unitaire tourne sur le runner en mémoire en mode distribué : chaque `await` suspend le fiber -et **rejoue le code du workflow depuis le début**. Aucune simulation de crash n'est nécessaire — le -rejeu est le régime normal. - -Sur un scénario à 3 appels modèle et 2 appels d'outil : **6 réexécutions** du code de workflow, et -pourtant l'activité d'appel modèle s'exécute **exactement 3 fois**, celle d'appel d'outil -**exactement 2 fois**. Le journal court-circuite le rejeu ; rien n'est repayé. - -Et les charges sortantes sont **identiques entre deux exécutions indépendantes** — vérifié par -mutation : un `uniqid()` glissé dans le prompt fait rougir l'assertion. C'est ce qui rend le rejeu -sûr, et c'est fragile : le prompt système et la liste d'outils doivent être **journalisés**, pas -relus depuis la configuration au rejeu. Ajouter un outil change sinon le prompt rejoué. - -## Ce qui n'est pas prouvé - -- **Aucun vrai fournisseur.** Le convertisseur de réponses est écrit à la main sur la forme « chat - completions ». Brancher un `symfony/ai-*-platform` le remplacerait sans toucher au reste — mais ce - n'est pas fait. -- **Aucun crash inter-processus.** Le test tourne en mémoire. Le runner y rejoue pour de vrai, mais - un vrai redémarrage de processus reste à démontrer. -- **Les blocs de raisonnement passent, mais la signature reste dehors.** Le convertisseur ne lisait - d'abord que `content` et `tool_calls` : un `reasoning_content` journalisé était perdu — sans - exception et sans trace, le tour d'après partait simplement amputé. Il lit maintenant le champ et - rend un `MultiPartResult` ; `Message::toContent()` le déplie en `Thinking`, et - `AssistantMessageNormalizer` le remet sur le fil au tour suivant. Deux tests le tiennent, vérifiés - par mutation. - - Ce qui reste dehors, c'est la **signature** — le champ dont le docblock de `Thinking` dit qu'il - sert « to verify thinking blocks when they are replayed on a subsequent turn ». Aucun normaliseur - de `ai-platform` ne l'écrit : `AssistantMessageNormalizer` concatène le contenu dans - `reasoning_content` et ne lit jamais `getSignature()`. Le rejeu signé suppose donc un bridge - fournisseur qui remplace ce normaliseur — plausible, c'est à ça que sert le `Contract`, mais - invérifiable ici : aucun `ai-*-platform` n'est installé. - - **La leçon vaut au-delà du raisonnement.** Ce correctif n'en était pas un : c'est un **point de - changement**. Tant que le convertisseur laissait tomber le champ, il le laissait tomber *de la - même façon à chaque rejeu* — déterministe, donc sûr. Le jour où on le corrige, le journal contient - toujours le même JSON mais le convertisseur en extrait davantage : le message reconstruit porte un - champ de plus, et la charge du tour N+1 ne ressemble plus à celle qui avait été envoyée. Toutes les - exécutions en vol divergent. Ici c'est sans conséquence — un prototype, rien en vol. En production - ce genre de correction se déclare et se garde ; elle ne se glisse pas dans un patch. - - La contrepartie est bonne, et elle vient d'un choix : journaliser la réponse **brute** plutôt qu'un - DTO converti. Le raisonnement était déjà dans le journal de toutes les exécutions passées, avant - même que quelque chose le lise. Le journal transporte des champs que le convertisseur ne connaît - pas encore — c'est ce qui a rendu la correction gratuite. -- **La classification d'échec.** Une activité d'outil qui échoue tue aujourd'hui l'appel d'agent. - C'est un défaut, pas une décision. -- **Le socle bouge.** `symfony/ai` est en 0.x, treize versions mineures à ce jour, sans promesse de - compatibilité. Les quatre coutures utilisées sont des interfaces publiques, mais rien ne garantit - leur forme à la mineure suivante. C'est la raison pour laquelle ceci est un motif et non un paquet. diff --git a/documentation/user/use-cases/durable-agent.md b/documentation/user/use-cases/durable-agent.md deleted file mode 100644 index 8c336442..00000000 --- a/documentation/user/use-cases/durable-agent.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: An interruptible AI agent -weight: 20 ---- - -# An interruptible AI agent - -> [!WARNING] -> **Prototype.** The code described here lives on a branch that has not been merged, and there is -> no documented way to start it yet. This page publishes **the pattern**, not a package: the four -> decisions below apply to any Symfony AI agent, with or without the repository's code. - -## The problem - -An agent that calls tools spends minutes, sometimes hours, working. Meanwhile it does things that do -not undo: it sends an email, it charges a payment, it pushes a price to production. - -Two needs collide. First: **someone has to be able to say no** before the dangerous call, and that -someone is in a meeting — they will answer in ten minutes, not within an HTTP timeout's thirty -seconds. Second: **the process is going to restart.** A deploy, an OOM kill, a machine rotating out. -If the agent was seven tool calls into nine, you do not want to pay for the seven again. - -Symfony AI's `ToolCallRequested::deny()` hook answers the first need as long as nobody restarts: it -is synchronous and in-process. `maxToolCalls` is an in-memory counter. Both vanish with the process. - -## What was built - -Symfony AI's agent loop, **driven from workflow code**. A conversation is a workflow execution; -every human message is a signal. Between two messages the workflow is not waiting — it is suspended, -consuming nothing. - -The agent itself is unmodified. You compose an ordinary `Provider` with two implementations of your -own: - -| Seam | What it becomes | -|---|---| -| `ModelClientInterface` | an `await` on an activity — the only HTTP in the whole agent | -| `ToolExecutorInterface` | one `await` per tool call, preceded by the guard | -| `ToolboxInterface` | a plain schema registry; it no longer executes anything | - -`Agent::call()` is called as-is from the workflow. It does not know it is replayable. - -## The four decisions - -This is the reusable part. None of it requires a dependency. - -**1. The low seam is `ModelClientInterface`, not `PlatformInterface`.** This is what makes the -exercise short. `Provider::invoke()` turns the conversation into a flat array *before* it reaches -the client, and the raw response is JSON. At that point there is **nothing to translate**: no -`MessageBag`, no `Content`, no `Thinking`, no `Metadata`. Hooking one level higher, at -`PlatformInterface`, forces you to serialize the whole object tree — for the same result. - -**2. What protects you is classifying tools, not having modes.** Every tool carries an `effect`: -`read`, `write` or `external`. The current mode only consults that table. Saying "pushing a price is -`external`, not `write`" is the design act; the mode is merely its consequence. The default is -cautious — an unclassified tool counts as `external` — but that is no excuse for not classifying. - -**And most tools deserve nothing.** A tool needs execution safety if it answers yes to at least one -of these: - -1. does replaying it twice do harm? (charging twice, sending two emails) -2. can it succeed while a later step fails? — then it needs compensation -3. does it last longer than an HTTP request? — minutes, hours, days -4. does someone have to authorize it? - -Four noes — and that is the case for `search_product`, `read_stock`, `get_invoice` — and a plain -activity is enough. Wrapping everything manufactures the problem you claim to be solving. - -**3. The idempotency key comes from the workflow, not the tool.** It has to be deterministic on -replay, so it is derived from the execution id and the call id. A tool that builds its own key with -`uniqid()` breaks replay on the first restart — and that is the kind of failure you discover in -production. - -**4. Approval is a signal, with a human's deadline.** Not a synchronous `deny()`. And the deadline is -that of someone who reads, thinks and switches windows: the prototype is set to fifteen minutes. It -was first set to 120 seconds, and the approval card vanished under the eyes of the person reading it -— the agent answered "denied, no approval" without anyone having denied anything. - -## What Durable brings - -- **Human approval that survives a restart.** A workflow waiting three days for an approval signal - is a different class of thing from an in-process hook. -- **Saga compensation on non-idempotent tools.** The agent that sent the email and then crashed - needs its return leg. -- **Journaled bounds.** An iteration cap and a cost budget held in workflow state survive a crash; - an in-memory counter does not. - -## What it does not bring - -- **Not retries.** Those are table stakes, and `symfony/ai-failover-platform` already covers part of - it. Watch the inverse trap too: Durable retrying an activity that has itself already failed over - across three providers is 3×N billable calls. -- **Not reliability.** Durable execution makes a wrong agent **reliably wrong**, and makes an - infinite loop **infinitely durable**. Failure resilience and reliability are two different things; - the second needs evals, exit guardrails and bounds, none of which is Durable's business. -- **Not streaming.** An activity returns a value once. Journal the assembled result, stream on a - side channel. - -## What replay measured - -The unit test runs on the in-memory runner in distributed mode: every `await` suspends the fiber and -**replays the workflow code from the top**. No crash simulation is needed — replay is the normal -regime. - -On a scenario with 3 model calls and 2 tool calls: **6 re-executions** of the workflow code, and yet -the model-invocation activity runs **exactly 3 times** and the tool-call activity **exactly twice**. -The journal short-circuits replay; nothing is paid for twice. - -And outbound payloads are **byte-identical across two independent executions** — verified by -mutation: a `uniqid()` slipped into the prompt turns the assertion red. That is what makes replay -safe, and it is fragile: the system prompt and the tool list must be **journaled**, not re-read from -configuration on replay. Otherwise adding a tool changes the replayed prompt. - -## What is not proven - -- **No real provider.** The response converter is hand-written against the "chat completions" shape. - Plugging in a `symfony/ai-*-platform` would replace it without touching anything else — but that - has not been done. -- **No cross-process crash.** The test runs in memory. The runner replays for real there, but an - actual process restart remains to be demonstrated. -- **Reasoning blocks now travel; the signature does not.** The converter first read only `content` - and `tool_calls`, so a journaled `reasoning_content` was lost — no exception, no trace, just an - amputated following turn. It now reads the field and returns a `MultiPartResult`; - `Message::toContent()` unrolls it into a `Thinking`, and `AssistantMessageNormalizer` puts it back - on the wire on the next turn. Two tests hold it, both verified by mutation. - - What stays out is the **signature** — the field whose docblock on `Thinking` says it serves "to - verify thinking blocks when they are replayed on a subsequent turn". No normalizer in - `ai-platform` writes it: `AssistantMessageNormalizer` concatenates the content into - `reasoning_content` and never reads `getSignature()`. Signed replay therefore assumes a provider - bridge replacing that normalizer — plausible, that is what `Contract` is for, but unverifiable - here: no `ai-*-platform` is installed. - - **The lesson outlives the reasoning field.** This was not a bug fix, it was a **change point**. As - long as the converter dropped the field, it dropped it *the same way on every replay* — - deterministic, therefore safe. The day it is fixed, the journal still holds the same JSON but the - converter extracts more from it: the reconstructed message carries one more field, and the payload - for turn N+1 no longer matches what was originally sent. Every in-flight execution diverges. Here - that costs nothing — a prototype, nothing in flight. In production this kind of fix gets declared - and guarded; it does not slip into a patch. - - The upside came from one choice: journaling the **raw** response rather than a converted DTO. The - reasoning was already in the journal of every past execution, before anything read it. The journal - carries fields the converter does not know about yet — that is what made the fix free. -- **Failure classification.** A failing tool activity currently kills the agent call. That is a - default, not a decision. -- **The ground moves.** `symfony/ai` is 0.x, thirteen minor versions so far, with no compatibility - promise. The four seams used are public interfaces, but nothing guarantees their shape at the next - minor. That is why this is a pattern and not a package. From 0395075402db824549d48d7e6d139794cceb4efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Fri, 4 Sep 2026 02:00:19 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(site):=20la=20page=20dit=20comment=20l?= =?UTF-8?q?ancer=20la=20d=C3=A9monstration,=20en=20entier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Comment on la lance » ne la lançait pas. La page donnait `demo/lancer.sh` et ses deux options, et taisait `bin/demo-nexus`, qui crée les namespaces et les endpoints Nexus. Les huit workers se connectent à des endpoints qui n'existent pas tant qu'il n'est pas passé : un lecteur qui suit la page obtient huit workers qui échouent à se connecter, sur un message qui ne nomme pas l'étape manquante. `demo/README.md:147-148` porte la séquence complète depuis toujours ; c'est la page publiée qui n'en reprenait que la seconde moitié. Les décalages de ligne entre `nexus-demo.md` et `nexus-demo.fr.md` sont inchangés. Non repris, faute d'avoir pu le reproduire : la relecture signalait une collision entre `/docs/use-cases/` et une URL existante du site publié. Aucune recherche dans `hugo-docs/` ne trouve de cible portant ce chemin, et le mount ne monte que `../documentation/user`. Le constat reste peut-être vrai sur un build complet — il n'est pas vérifié, donc il n'est pas corrigé ici. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/user/use-cases/nexus-demo.fr.md | 7 ++++++- documentation/user/use-cases/nexus-demo.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/documentation/user/use-cases/nexus-demo.fr.md b/documentation/user/use-cases/nexus-demo.fr.md index 9068c492..b2feab2b 100644 --- a/documentation/user/use-cases/nexus-demo.fr.md +++ b/documentation/user/use-cases/nexus-demo.fr.md @@ -69,11 +69,16 @@ rejouer la même commande rend le même verdict et ne retient pas de stock une s ## Comment on la lance ```bash -demo/lancer.sh # démarre les huit workers +bin/demo-nexus # d'abord : les namespaces et les endpoints Nexus +demo/lancer.sh # ensuite : les huit workers demo/lancer.sh --etat # dit qui tourne demo/lancer.sh --arreter # les arrête ``` +`bin/demo-nexus` passe en premier, et il n'est pas facultatif : les workers se connectent à des +endpoints qui n'existent pas tant qu'il ne les a pas créés. Les deux scripts impriment, une fois +finis, les commandes d'appel avec les bonnes valeurs. + L'ordre de démarrage n'a pas d'importance : un worker en retard fait attendre, il ne fait pas échouer. diff --git a/documentation/user/use-cases/nexus-demo.md b/documentation/user/use-cases/nexus-demo.md index fa8833ee..eb12cf3d 100644 --- a/documentation/user/use-cases/nexus-demo.md +++ b/documentation/user/use-cases/nexus-demo.md @@ -67,11 +67,16 @@ provide it. ## How to run it ```bash -demo/lancer.sh # start the eight workers +bin/demo-nexus # first: the namespaces and the Nexus endpoints +demo/lancer.sh # then: the eight workers demo/lancer.sh --etat # report who is running demo/lancer.sh --arreter # stop them ``` +`bin/demo-nexus` comes first and is not optional: the workers connect to endpoints that do not +exist until it has created them. Both scripts print the call commands with the right values once +they are done. + Start order does not matter: a late worker makes things wait, it does not make them fail. Two prerequisites you would not guess, detailed in