Skip to content

spike(ai): a durable Symfony AI agent, driven from workflow code - #393

Open
gplanchat wants to merge 49 commits into
mainfrom
spike/agent-durable-symfony-ai
Open

gplanchat wants to merge 49 commits into
mainfrom
spike/agent-durable-symfony-ai

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 12, 2026

Copy link
Copy Markdown
Owner

What this is

A spike, confined to symfony/ (the sample application): Symfony AI's agent loop run from
workflow code, so that a conversation is a workflow execution, a message is a signal, and the
agent survives a restart between two tool calls. No package, no bundle, no ADR.

The agent itself is not modified. A regular Provider is composed with two implementations of
ours: ModelClientInterface becomes an await on an activity (the only HTTP call in the whole
agent), ToolExecutorInterface becomes an await per tool call, preceded by a guard. Runner,
which is @internal, is never touched. symfony/ai is pinned to v0.13.0, the latest release,
because 0.x makes no compatibility promise between minors.

What the demo shows

  • A guard on tool calls. Each tool carries an effect (read, write, external); the mode
    (standard, edition, auto) decides what passes without asking. Anything else suspends the
    workflow until a tool_decision signal, with a deadline.
  • Questions to the human, as a questionnaire the workflow waits on.
  • Watch and alert. The agent can go to sleep on a business fact and wake up knowing what it was
    doing; app:agent:evenement raises such a fact from the CLI.
  • A context budget on the model call, and a cold resume that starts a new execution from a
    summary of the previous one. ?contexte=N makes compaction observable by hand.
  • Delegation to a sub-agent, which never gets more authority than its parent.
  • Reasoning blocks cross the boundary and show in the thread, folded.
  • The Mistral bridge, taking only its pure parts (normalizers, result converter) — the HTTP
    client is replaced. Without MISTRAL_API_KEY, a scripted client answers: the demo runs with no
    key and no network.
  • Mercure for what arrives from outside the page; the page also polls, so the hub is optional.

How to run it

documentation/user/use-cases/durable-agent.md (and its French mirror) is back — 761ea76 had
removed it for lack of a launch path — with a "What the demo shows" section and a launch path
that needs PHP 8.2 with ext-grpc, Composer and Docker, nothing else:

cd symfony
composer install
docker compose up -d --wait
php bin/console messenger:consume durable_temporal_journal    # terminal 1
php bin/console messenger:consume durable_temporal_activity   # terminal 2
php -S localhost:8012 -t public                               # terminal 3

Then http://localhost:8012/durable/chat. This path was run end to end, including a from-scratch
composer install. For it to work on any machine, the last commits make the per-facet hosts
(samples.durable.localhost, agent.durable.localhost) an opt-in through .env.local; empty by
default, they put no host constraint on the routes. Mercure's CORS is opened to any origin, since
the page's port is whoever launches it's choice.

The use-cases section itself is not on this branch (it is 373 commits behind main); the table
row in _index.md / _index.fr.md goes back at merge time, and 761ea76 shows which lines.

To be aware of

  • symfony/composer.json and composer.lock change: symfony/ai-agent,
    symfony/ai-mistral-platform (both v0.13.0), symfony/mercure-bundle, symfony/lock.
  • The branch carries fix(temporal): do not cancel an already-cancelled timer, with
    TimerCancelNotRepeatedTest, in src/Bridge/Temporal. It was found by the demo.
  • git merge-tree against main reports no conflicts.
  • With symfony serve, the Docker stack is one of the workers in .symfony.local.yaml: it lives
    and dies with the server, and a worker that loses Temporal exits rather than retrying.
  • Not proven, as the page says: nothing in the suite exercises the Mistral path; no scripted
    cross-process crash; the reasoning signature is not carried by any ai-platform normalizer.

gplanchat and others added 30 commits August 31, 2026 21:16
Rend `Runner::run()` de symfony/ai exécutable en code workflow : ses deux jambes
non déterministes passent par le journal, la boucle se rejoue, la MessageBag se
reconstruit seule.

La couture basse est `ModelClientInterface`, pas `PlatformInterface` : `Provider`
a déjà normalisé la conversation et les schémas d'outils en tableaux via
`Contract::createRequestPayload()`, il ne reste que l'appel HTTP. Aucun mapping
DTO de MessageBag / Content / Metadata n'est nécessaire.

Mesuré : 6 réexécutions du code workflow pour 3 appels modèle et 2 appels
d'outil ; payloads sortants identiques entre deux exécutions indépendantes.

Branche de spike, non publiée. symfony/ai épinglé v0.13.0 : `Runner` est
`@internal`, sa boucle est le contrat de déterminisme du rejeu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… nommés

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… au catalogue

Le workflow Ai_DurableAgent est enregistré (tag durable.workflow sur src/Ai/Workflow/),
l'outil weather passe par un service locator tagué, et l'appel modèle est servi par un
client scripté : la démo tourne sans clé d'API et reste déterministe, donc rejouable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…= un signal

Le fil est une projection du journal (chaque ai_model_invoke porte la conversation
du tour en entrée), pas un état stocké à côté. Entre deux messages le workflow est
suspendu, pas en attente dans un processus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HTTP

L'event store in-memory faisait disparaître le WorkflowSignalReceived écrit par
DeliverWorkflowSignalHandler : un signal envoyé depuis une autre requête n'atteignait
jamais l'exécution suspendue. Backend DBAL (DUR030), journal en SQL, temporal.journal=false
pour garder le cluster joignable (l'app déclare un handler Nexus).

DoctrineBundle n'était pas enregistré ; files Messenger suffixées _spike, la base de dev
partagée porte des messages d'une classe disparue qui bloquent tout consommateur.

Vérifié : POST message -> signal -> le workflow suspendu reprend, appelle l'outil et répond.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, accord par signal

Chaque appel d'outil passe par un ToolGuardInterface évalué en code workflow (donc pur,
donc rejoué). La décision croise le mode et l'effet déclaré de l'outil (read/write/external),
une liste de refus l'emportant toujours :

  mode      | read  | write   | external
  auto      | passe | passe   | passe
  edition   | passe | passe   | demande
  standard  | passe | demande | demande

Une demande suspend le workflow sur le signal tool_decision — pas une attente dans un
processus : l'accord peut arriver demain, après un redéploiement. Un refus n'est pas une
exception, il redevient un ToolResult rendu au modèle, qui s'adapte.

Le mode est lui-même de l'état de workflow, changeable par signal set_mode.

Vérifié sur la stack : standard suspend send_email, le refus revient au modèle, set_mode auto
le laisse passer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plus de bandeau séparé : la carte d'accord est une bulle du fil, posée à la place de
l'appel d'outil qu'elle retient — donc après le message qui l'a déclenchée, là où on la
cherche. Une fois la décision prise, la bulle laisse la place à l'appel puis à son
résultat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…DurableAgent

Ai_DurableChat était un sur-ensemble strict : une boucle de N tours dont le sample était
le cas N=1 sans signaux. Deux noms pour un concept. Le type unique prend un prompt
optionnel — avec maxTurns:1 il se termine et sert le sample, sans prompt il est un chat
piloté par signaux.

Corrige une régression que le passage en DBAL avait provoquée sur TOUS les samples :
DurableSampleWorkflowRunner sonde Temporal dès qu'un WorkflowClient existe, or le cluster
reste joignable pour Nexus alors que le journal est en SQL. Port Postgres épinglé aussi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…statut au-dessus du champ

VO : ToolDefinition, PendingApproval, Transcript / TranscriptMessage / ToolCallRef /
ToolStep, AgentMode et Duration typés jusque dans la projection. Les tableaux ne restent
que là où ils sont le fil — payload du fournisseur, charge d'activité, schéma JSON d'outil —
avec une fabrique de frontière (listFromWire / fromWire) à l'entrée.

Échéance : approvalTimeoutSeconds, globale à l'instance d'agent, portée par un minuteur du
journal (DUR032). Pas de réponse vaut refus, et le refus redevient un ToolResult rendu au
modèle. La décision est inscrite dans la porte pour que le rejeu la relise au lieu de
replanifier un minuteur déjà tiré.

La projection lit désormais la charge de démarrage dans le store de métadonnées : un run
dispatché n'écrit pas d'ExecutionStarted, seulement ses événements d'exécution.

UI : le statut de l'agent passe juste au-dessus du champ de saisie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ToolApprovalGate::timeout() à côté de decide() : « refusé » et « jamais répondu » ont le
même effet — l'outil ne part pas — mais ne disent pas la même chose. L'un est une décision,
l'autre est son absence, et un journal qui les confond ne permet plus de savoir si quelqu'un
a regardé.

ApprovalOutcome porte les trois cas et la phrase que le modèle lit à la place du résultat.
La première issue gagne : un signal arrivé après le tir de l'échéance ne ressuscite pas un
appel déjà tranché, sinon l'ordre du journal donnerait le verdict inverse au rejeu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chaque enum répond à la question qu'on lui pose au lieu de se faire comparer :

  ApprovalOutcome::isApproved() / isExpired()
  ToolEffect::isHarmless() / isIrreversible()
  ToolDecision::isAllowed() / needsApproval() / isDenied()  (le verdict devient privé)
  AgentMode::requiresApprovalFor(ToolEffect)

Effet de bord voulu : la matrice mode × effet quitte ModeToolGuard pour AgentMode, où
elle appartient — une garde ne devrait pas connaître la table, seulement demander au mode.
ToolVerdict n'est plus manipulé hors de ToolDecision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le cluster redevient le journal. Ce qui change vraiment, c'est le chemin d'un signal :
TemporalReadThroughEventStore::append() n'écrit que dans le cache local de la requête, donc
un WorkflowSignalReceived posé par DeliverWorkflowSignalHandler y disparaît. Le contrôleur
signale par WorkflowClient::signal() quand un client Temporal existe, et retombe sur le
message Messenger sinon — la démo tourne sur les deux backends.

Deux asymétries relevées en chemin :
- la charge de démarrage ouvre le journal sur Temporal (ExecutionStarted) mais reste dans le
  store de métadonnées sur DBAL ; la projection lit les deux ;
- ActivityScheduled porte les arguments de l'activité en mémoire, l'enveloppe ActivityMessage
  qui les contient sur Temporal. La projection descend jusqu'à la clé attendue au lieu de
  coder une profondeur.

Vérifié sur la stack : sample synchrone, démarrage du chat, message, garde qui suspend en
mode standard, refus qui revient au modèle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mencé »

La dérivation comptait `null === lastModelCallId` comme un tour en cours, alors que c'est
l'état de départ : avant le premier prompt le workflow est suspendu sur son signal.

Un tour est en cours de trois façons, toutes lues au journal : un message signalé que le
dernier appel modèle ne contient pas encore, un appel modèle planifié sans résultat, ou une
réponse qui demande encore des outils. Aucun appel modèle du tout n'en est pas une.

Côté page, la bulle optimiste disparaît dès que le journal porte le message plutôt qu'au
prochain état inactif — sinon elle faisait doublon — et elle tient le statut « travaille »
pendant le trajet du signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'échéance était à 120 s — choisie pour que mes tests soient observables, pas pour un
humain qui lit, réfléchit et change de fenêtre. La carte disparaissait pendant la lecture
et l'agent répondait « refusé faute de validation » sans que personne n'ait rien refusé.
Un quart d'heure, et surtout un compte à rebours visible sur la carte.

L'instant d'expiration vient du minuteur du journal, pas de l'ouverture de la page : un
rechargement ne remet pas l'échéance à zéro.

Troisième asymétrie entre backends relevée par ce spike : TimerScheduled::scheduledAt()
porte l'instant de tir dans le cœur et l'instant de départ dans le pont Temporal, dont le
convertisseur laisse tomber startToFireTimeout et le résumé. Tant que l'attente dure le tir
est forcément à venir — c'est ce qui permet de trancher sans deviner le backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rs Temporal

« suspendu — en attente d'un message » disait la mécanique interne au lieu de dire à
l'utilisateur qu'il peut écrire.

bin/spike-workers.sh démarre les deux workers dont la démo a besoin — séparés, parce que
les transports Temporal sont des long-polls gRPC qui affament tout transport partageant
leur boucle — et les détache (setsid) pour qu'ils survivent au shell qui les lance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le vocabulaire maison — rejeu, compensation, minuteurs, signaux, validations — fait
d'excellentes blagues quand on le prend au premier degré. Une phrase par période de
travail, renouvelée toutes les quatre secondes, jamais deux fois la même d'affilée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PHP meurt tous les ans depuis 1998, les elePHPants se collectionnent, personne ne se
souvient de l'ordre des arguments d'array_filter, et PHP 6 n'est jamais sorti. Trente ans
de mèmes valent mieux que douze jeux de mots sur le rejeu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neuf de plus, du même folklore : Xdebug qu'on attend, le baseline PHPStan qui grossit,
le polyfill d'une fonction native, preg_match et ses trois valeurs de retour, et le @ qui
éteint ce qu'on ne veut pas voir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une échéance réglée par l'autre branche fait annuler le minuteur perdant. Le replay repasse
par cette annulation à chaque reprise, et un minuteur annulé n'a pas de verdict à annoncer :
findTimerSlotResult() rend null, le minuteur revient en attente, l'annulation est redemandée.

Le journal SQL s'en gardait déjà — EventStoreCommandBuffer::cancelTimer() relit le flux avant
d'appendre, et son commentaire nomme exactement ce cas. Le pont Temporal ne s'en gardait pas,
et le serveur ne pardonne pas : la tâche entière est rejetée avec

  BadCancelTimerAttributes: invalid history builder state for action: add-timer-canceled-event

le worker meurt, la tâche est redélivrée, le worker meurt encore. Une seule exécution
empoisonnait toute la file durable-journal et affamait toutes les autres.

TemporalExecutionHistory lit désormais EVENT_TYPE_TIMER_CANCELED, qu'elle ignorait, et expose
isTimerSettled() ; le tampon de commandes s'en sert comme il se sert déjà de l'historique pour
cancelActivity().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'outil `demander_a_l_utilisateur` est le seul dont l'exécution est une suspension : son
résultat n'est pas calculé, il est attendu. Le modèle propose une question, un libellé court
et deux à quatre options ; le workflow suspend sur un signal `question_answered`, et la
réponse revient au modèle à la place d'un résultat d'outil.

C'est l'inverse de la garde, avec la même primitive. La garde décide si un outil que le modèle
a choisi peut partir ; le guichet va chercher ce que le modèle ne sait pas. HumanQuestionDesk
est le jumeau de ToolApprovalGate par la forme, pas par le rôle — et comme lui, la première
réponse gagne, sinon l'ordre du journal donnerait le verdict inverse au rejeu.

L'outil est toujours offert : un agent qui ne peut pas demander invente. Classé `read`, pour
que personne n'ait à autoriser une question avant d'y répondre.

`approvalTimeoutSeconds` devient `humanTimeoutSeconds` : c'est la même chose — combien de temps
on attend un humain — et le nom ne parlait que d'une des deux attentes. Sans réponse, l'agent
reçoit une phrase qui le lui dit et poursuit, au lieu de rester planté.

Côté page, la carte questionnaire vit dans le fil comme la carte de validation, partage son
compte à rebours, et laisse toujours une réponse libre : les options du modèle ne sont que des
propositions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux cas qui le prouvent plutôt que de l'affirmer :

- en mode `standard`, poser une question passe sans validation — l'outil est classé `read`.
  Si la garde le retenait, le test resterait suspendu faute de signal `tool_decision`, et c'est
  exactement ce qu'il vérifie ;
- une politique qui met `demander_a_l_utilisateur` en liste de refus l'interdit pour de bon,
  sans qu'aucun signal ne soit déposé. Si le guichet passait avant la garde, l'exécution
  suspendrait au lieu de rendre « interdit par la politique ».

Vérifié par mutation : classer l'outil `external` fait rougir le premier avec une
WorkflowStuckException sur la condition d'approbation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un vrai modèle décide seul de demander ; un modèle scripté a besoin qu'on le lui dise.
« demande-moi… » pose une question à trois options, « choix multiple » dans la même phrase
bascule la carte en sélection multiple.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le pont fournit la normalisation de la conversation, le catalogue et la conversion du JSON en
résultat — tout cela est pur, donc tourne en code workflow et se rejoue. Seul son client HTTP
est remplacé par DurableModelClient : lui seul sort du processus, et c'est précisément ce qui
doit devenir une activité. C'est la couture identifiée dès le premier jour du spike, et elle
tient avec un vrai pont.

Deux choses que le pont a révélées :

- son convertisseur n'est pas une fonction `tableau → résultat` : il commence par regarder le
  code HTTP, et exige une vraie ResponseInterface. Au rejeu il n'y a plus de socket, d'où
  JournaledHttpResponse — un 200 et un corps ;
- ce qui est journalisé est donc toujours un succès. L'erreur HTTP appartient à l'activité :
  ModelInvocationActivityHandler relève sur tout code >= 400, pour qu'un 429 ou un 503
  rencontre la politique de retentative de Durable (DUR011) plutôt que d'étrangler le
  convertisseur au rejeu.

Sans MISTRAL_API_KEY, ModelClientFactory rend le client scripté : la démo tourne sans réseau
ni clé, et tout le reste — journal, garde, guichet, échéances — se comporte à l'identique.

ChatCompletionResultConverter disparaît : le convertisseur du pont le remplace, ce qui était
sa raison d'être annoncée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt ce qu'il faisait

`surveiller` est la seconde suspension de l'agent, après la question. Ce qui la lève ne vient
pas d'un humain devant une carte mais du dehors : une supervision, un webhook, un autre agent
signalent `alerte`.

Le point qui la distingue d'un watcher de processus : **l'intention est écrite au journal au
moment de l'inscription**, pas reconstruite au réveil. Trois jours et un redéploiement plus
tard, ce que le modèle relit est « Alerte sur X : Y. Tu comptais : Z » — il n'a rien à se
rappeler, le journal le lui dit. Sans alerte, l'échéance rend la main en rappelant l'intention
elle aussi.

WatchDesk est le troisième objet de cette forme après ToolApprovalGate et HumanQuestionDesk :
une file par identifiant d'appel, réglée par signal, la première issue gagnant. Ils ne sont pas
fusionnés parce que ce qu'ils portent diffère — une issue à trois cas, des réponses, une
observation — et qu'un objet générique remplacerait trois types clairs par un `mixed`.

La garde reste devant : `surveiller` est classée `read` (se mettre en veille n'écrit nulle
part), et ce que l'agent fait *au réveil* repasse par elle comme n'importe quel appel.

Côté page, la carte de veille montre ce qui est guetté et ce qui est prévu, et porte le bouton
qui lève l'alerte — le rôle qu'une supervision tiendrait en production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La garde des outils protège des effets ; celle-ci protège de l'asphyxie. Une conversation
durable grossit sans fin — c'est le prix du journal — et le jour où elle dépasse la fenêtre,
l'agent ne rate pas un tour : il ne peut plus en faire un seul.

Deux chemins, dans cet ordre :

- **préventif** — avant chaque appel, ContextBudget::fit() ramène la conversation sous le
  plafond en abandonnant les tours les plus anciens. Par tours entiers, jamais par messages :
  un `assistant` qui demande des outils et les `tool` qui lui répondent forment un bloc, et
  couper au milieu laisse un résultat orphelin que les fournisseurs refusent. Le message
  système reste, et un marqueur dit combien de messages ont disparu ;
- **réactif** — si le fournisseur compte autrement que nous, ContextOverflow le reconnaît et
  l'appel repart avec un budget de moitié. Ce n'est pas une panne mais une réponse : la
  rejouer donnerait le même verdict, donc l'activité la journalise comme une donnée au lieu de
  relever, et la politique de retentative de Durable ne s'en mêle pas (DUR011).

La compaction est **pure** — pas d'horloge, pas de hasard, pas de résumé par le modèle. Elle
tourne en code workflow, donc elle est rejouée ; un résumé produit par un modèle serait une
seconde source de non-déterminisme là où on essaie justement d'en supprimer. Sa place est dans
un `continueAsNew`, en charge de départ.

Plafond assumé et testé : un tour à lui seul plus gros que la fenêtre n'est pas compacté —
abandonner le message auquel il faut répondre n'aurait pas de sens. Il part tel quel, et c'est
le chemin réactif qui reprend la main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Une conversation de démo n'atteint jamais 24 000 jetons. Le paramètre ouvre une conversation à
budget minuscule : la compaction se déclenche alors en quatre ou cinq messages.

Vérifié sur la stack, ?contexte=200 : après cinq tours, le payload parti à l'activité ne porte
plus que le dernier tour — les précédents ont été abandonnés par tours entiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContextBudget manipulait `list<array{role, content, tool_calls…}>` en s'appuyant sur une
convention : ne jamais couper entre un appel d'outil et son résultat. Une convention se perd de
vue ; un type non.

Conversation porte le préambule système et une liste de Turn ; Turn est l'unité indivisible de
la compaction — le message de l'humain et tout ce que l'agent a produit en réponse. Le budget
n'a plus qu'à retirer des tours par le début, et `splitIntoTurns`/`flatten` disparaissent dans
la structure.

Les messages eux-mêmes restent des tableaux : c'est du fil, la forme appartient au fournisseur,
et la retyper reviendrait à réécrire son protocole. Fabrique de frontière dans les deux sens,
fromWire() / toWire().

Un piège que le type a rendu visible et que le test tient : seuls les messages système **de
tête** sont le préambule. Un marqueur de compaction posé au fil des tours remonterait en tête à
chaque passage, et la conversation finirait par en collectionner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux bornes ferment un run d'agent, et aucune ne perd le fil.

`idleTimeoutSeconds` — une heure de silence vaut une fin. L'attente sur le
prochain message porte désormais une échéance ; passée, l'exécution se
termine plutôt que de rester ouverte pour rien. Le minuteur est journalisé
(DUR032), donc l'échéance survit au redémarrage comme l'attente elle-même.

`rolloverAfterTurns` — au bout de N tours, `continueAsNew` ouvre un run neuf
dans la même exécution. Ce n'est pas le journal qui borne : à une dizaine
d'événements par tour, Temporal en tolère cent fois plus. C'est que chaque
tour renvoie tout le sac au modèle, donc la dépense d'une conversation croît
avec le carré de sa longueur.

Ce qui se transporte est le fil *parlé* : un `role: tool` et un message
d'assistant qui ne porte que des `tool_calls` racontent la mécanique du run
qui s'achève ; les rejouer remplirait le suivant de bulles vides et de
références d'appels qui n'existent plus.

Deux pièges de projection, tous deux vérifiés par un test :

- après un continue-as-new, le store de métadonnées porte encore la charge du
  **premier** run. L'événement du run courant passe donc devant, sinon la
  reprise afficherait un fil vide ;
- le fil repris entre dans le sac dès le premier appel modèle. Sans le
  compter du côté des messages reçus, `messagesSignalled` (ce run seul) et
  `messagesSeenByModel` (le sac entier) cessent de parler de la même chose,
  et l'agent paraît au repos pendant qu'il travaille.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'événement `WorkflowContinuedAsNew` prouvait que la charge partait ; rien ne
prouvait qu'elle arrivait. Le test pilote maintenant le run suivant avec cette
charge et relit sa projection : le modèle doit voir le fil repris *et* le
nouveau message.

Vérifié par mutation — remplacer le fil repris par un tableau vide fait passer
la projection de 4 messages à 2, et le test tombe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reprendre une conversation après une heure de silence en la rejouant mot pour
mot ferait payer au premier tour tout ce que le run précédent avait déjà
coûté. La reprise passe donc le fil au modèle, qui en rend un résumé, et c'est
ce résumé qui ouvre le sac.

L'appel sort du journal comme les autres — donc rejoué, donc payé une fois
même si la reprise redémarre. Une activité, pas une boucle d'agent : il n'y a
rien à outiller ici, et passer par `Runner` exposerait la compaction aux
gardes et aux appels d'outils.

Deux décisions qui ne sont pas cosmétiques, chacune tenue par un test :

- l'activité s'appelle `ai_model_compact`, pas `ai_model_invoke`. La
  projection recompose le fil à partir du dernier `ai_model_invoke`, et la
  charge d'une compaction est justement la conversation qu'on remplace : sous
  le même nom, une reprise restée silencieuse réafficherait tout l'ancien fil.
  Vérifié par mutation — un seul nom, et l'affichage passe de 1 message à 3 ;
- la projection lit le résumé et l'affiche dès avant le premier tour. Sinon la
  page montrerait le fil complet puis le verrait s'effondrer d'un coup, et le
  statut de l'agent compterait des messages que le modèle ne verra jamais.

Le préfixe du résumé est une fabrique partagée (`TranscriptMessage::compaction`)
et non deux `sprintf` : le workflow le construit pour le modèle, la projection
le reconstruit pour l'affichage, et les deux doivent tomber sur le même texte.

Le relais en cours de conversation, lui, transmet le fil tel quel : il a lieu
au milieu d'un échange vivant, où perdre le détail se paierait tout de suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gplanchat and others added 19 commits September 2, 2026 17:19
Trois finitions sur la compaction, trouvées en tirant le fil du deuxième
retour — le cas ordinaire de qui revient deux fois dans une conversation.

L'étiquette « Résumé de notre conversation précédente » est pour l'humain.
Elle repartait au modèle à la compaction suivante, qui résumait donc un résumé
étiqueté. `stripped()` la retire à la frontière ; le test vise la charge
envoyée, pas ce qui en revient — visé de l'autre côté il ne prouvait rien, le
client scripté ne reproduisant pas l'imbrication.

Un résumé vide n'est plus un résumé raboté : le fil repart tel quel. C'est le
seul choix qui garde l'affichage et le sac d'accord, la projection retombant
elle aussi sur le fil brut quand le journal ne porte pas de résumé exploitable.

Et l'index de la compaction n'est lu que s'il existe, plutôt que d'indexer
`$results` sur une clé nulle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ingue

Les samples et l'agent partageaient tout : servis par deux instances de la
même application, chacune répondant à tout, sur deux domaines. On voyait deux
fois la même chose.

Désormais une seule instance derrière les deux domaines, et c'est la
contrainte d'hôte qui tranche — posée au niveau de la classe, ce qui fait de
l'hôte une propriété de la facette et non de chaque route :

    samples.durable.localhost → samples, tableau de bord, documentation
    agent.durable.localhost   → la démo d'agent durable

Un lien d'une facette vers l'autre sort en URL absolue tout seul : Symfony
voit que l'hôte diffère. Le « retour aux samples » du chat rend bien
`//samples.durable.localhost/`.

Les hôtes viennent de l'environnement (`APP_HOST_SAMPLES`, `APP_HOST_AGENT`)
et restent actifs en test — les neutraliser ferait passer des routes que le
navigateur ne verrait jamais. `DEFAULT_URI` fixant l'hôte du client à
`localhost`, c'est celui des samples : les tests fonctionnels existants
tombent du bon côté sans y toucher.

La racine du domaine de l'agent mène au chat, comme celle des samples mène
aux samples. Deux `/`, deux hôtes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le convertisseur ne lisait que `content` et `tool_calls`. Un `reasoning_content`
dans la réponse journalisée était donc perdu : aucun `ThinkingResult` produit,
aucun `Thinking` dans le sac, rien qui reparte au tour suivant. Sans exception
et sans trace — le tour d'après partait simplement amputé. Chez un fournisseur
qui vérifie ses blocs au renvoi (c'est ce à quoi sert le `signature` que porte
`Thinking`), l'appel aurait échoué au deuxième tour.

Le journal, lui, portait déjà ce qu'il fallait : l'activité rend `getData()`,
donc le JSON brut entier. Le correctif est du code de workflow pur sur des
données déjà là, pas un re-appel.

Trois changements :

- `ChatCompletionResultConverter` lit `reasoning_content` (ou `reasoning`) et
  rend un `MultiPartResult` quand il y a du raisonnement à côté du texte ou des
  appels d'outil. `Message::toContent()` le déplie, `AssistantMessageNormalizer`
  le remet en `reasoning_content` au tour suivant.
- `DurableAgentWorkflow` prend la réponse par `asText()` sur un multi-parts :
  `getContent()` y rend un tableau, et le fil affichait « Array ».
- `ScriptedChatModelClient` en émet, sinon rien ne l'exerce. Le raisonnement
  reste une fonction pure de la conversation reçue, donc rejouable.

Deux tests, tous deux vérifiés par mutation : le raisonnement du tour N est
présent dans la charge du tour N+1 (rouge sans le convertisseur), et la réponse
reste du texte (rouge sans `asText()` — « Array »).

Ce que ça ne fait pas : la signature. Aucun normaliseur de `ai-platform` ne
l'écrit sur le fil — `AssistantMessageNormalizer` concatène le contenu dans
`reasoning_content` et ne lit jamais `getSignature()`. Le rejeu signé suppose
un bridge fournisseur qui remplace ce normaliseur ; aucun n'est installé ici.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Les blocs traversaient la frontière depuis le commit précédent, mais personne
ne les voyait. Trois changements, un par étage.

`TranscriptMessage` porte un `reasoning`, que `fromWire()` lit dans
`reasoning_content` — la clé que le normaliseur de Symfony AI écrit déjà.
`toWire()` **ne le rend pas**, et c'est délibéré : cette méthode alimente deux
charges qui partent au modèle (la compaction, et le fil transmis par le relais).
Y ajouter une clé changerait une charge sortante, donc ferait diverger un rejeu.
Conséquence assumée : après un relais, le fil repris n'a plus ses blocs.

`ChatTranscript` le lit à deux endroits, et il faut les deux : le raisonnement
d'un tour passé revient dans la charge du tour suivant (le normaliseur l'y
remet), celui du dernier tour n'a pas de tour suivant et ne vit que dans le
résultat de l'activité.

Le fil l'affiche dans un `<details>` replié, avant la bulle du tour — c'est ce
qui l'a précédé, pas un commentaire d'après coup. Replié parce qu'un
raisonnement se consulte, il ne se lit pas ; `<details>` parce qu'il fait déjà
l'ouverture, le clavier et l'état.

Deux tests de projection, vérifiés par mutation : couper la lecture du wire
perd le tour passé, couper la lecture du résultat perd le dernier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seules les branches d'appel d'outil portaient un `reasoning_content` : la
réponse finale arrivait donc sans, et le fil n'affichait de raisonnement que sur
les tours mécaniques.

Vérifié de bout en bout contre Temporal — les deux chemins de la projection
rendent bien, celui du wire (tour passé) et celui du résultat d'activité
(dernier tour).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment dans le même fil

Les deux branches divergeaient depuis 64cdd1a, huit commits de chaque côté et
les mêmes fichiers touchés. Quatre conflits de contenu, un de suppression.

**Le convertisseur écrit à la main disparaît.** `ChatCompletionResultConverter`
était supprimé côté questionnaires au profit du vrai pont Mistral. On prend la
suppression : son `ResultConverter` fait tout ce que le nôtre faisait, et mieux
— il rend un `ThinkingResult` et un `MultiPartResult` là où le nôtre bricolait.

**Mais il change la forme du raisonnement, et ça se paie.** Vérifié dans le
pont : `AssistantMessageNormalizer` de Mistral écrit les blocs en morceaux
`thinking`/`text` dans `content`, et son docblock dit pourquoi — le
`reasoning_content` du contrat générique est **refusé par un 422** « Extra
inputs are not permitted ». Trois conséquences, toutes appliquées :

- le modèle scripté émet désormais la forme Mistral sur ses tours parlés ;
- `TranscriptMessage::splitContent()` lit les deux formes — la nouvelle, et
  l'ancienne pour les journaux écrits avant le pont : le fil est une projection,
  et changer de pont ne doit pas rendre illisibles les conversations d'avant ;
- **un tour d'appel d'outil ne peut plus porter de raisonnement du tout.**
  `CompletionsConversionTrait::convertChoice()` rend un `ToolCallResult` nu dès
  que `finish_reason` vaut `tool_calls`. Un test le fige, pour que ça se voie le
  jour où le pont changera d'avis.

`asText()` sur le résultat, ajouté pour les blocs de raisonnement, devient
porteur : le convertisseur Mistral rend vraiment un `MultiPartResult`, et sans
lui le fil afficherait « Array » à la place de la réponse.

**Le reste des conflits.** `approvalTimeoutSeconds` devient `humanTimeoutSeconds`
partout — une échéance humaine, validation comme question. `working` combine les
deux lectures : quatre façons d'avoir un tour en cours (compaction comprise, du
côté agent), et trois façons de ne pas travailler sans avoir fini — la balle est
chez l'humain. `startAgent()` garde sa reprise et son fil, et gagne le levier
`?contexte=N`.

126 tests, 280 assertions, vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le fil ne montrait rien entre le message envoyé et la réponse : seule une
pastille, hors du fil, changeait de texte. Six attentes cohabitent maintenant —
appel modèle, activité d'outil, compaction, validation, question, veille — et
elles n'étaient pas distinguables.

Deux signes, parce qu'il y a deux natures d'attente :

- **une roue qui tourne** quand la machine calcule : la pastille, une bulle de
  travail en fin de fil (avec le même labeur que la pastille, `labeurCourant()`
  étant stable quatre secondes), et l'appel d'outil dont l'activité est partie
  sans revenir ;
- **un battement** quand la balle est dans le camp de l'humain : validation,
  question, veille — carte et pastille.

Mettre la roue sur une validation ferait patienter quelqu'un devant sa propre
décision. C'est la même erreur qu'un tableau de bord qui cache une opération
Nexus : il envoie chercher l'attente au mauvais endroit.

`ToolStep` porte son `callId` jusqu'au fil : c'est ce qui permet de dire « cette
activité-ci tourne encore ». Apparier sur le nom et les arguments marcherait
jusqu'au premier agent qui appelle deux fois le même outil pareil.

`prefers-reduced-motion` garde les deux signes et arrête le mouvement — c'est
une information, pas une décoration.

Vérifié à l'écran, worker d'activité éteint pour tenir l'état en vol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un `<details>` déplié se refermait tout seul, et un champ à moitié rempli se
vidait. La cause n'est pas le rythme du sondage : c'est `replaceChildren()`
toutes les 1,2 s sur un fil où quelqu'un est en train d'agir. Un canal temps
réel aurait refermé les mêmes blocs, sur un autre signal.

Trois changements, aucun de transport :

- **on ne redessine que si le journal a bougé.** Une empreinte du fil (le JSON
  plus le message optimiste local) coupe le redessin ; seuls la pastille et le
  labeur, qui changent d'eux-mêmes toutes les quatre secondes, sont remis à jour
  sur ce chemin ;
- **une carte d'attente n'est construite qu'une fois.** Réutilisée d'un tour à
  l'autre par son `callId`, elle garde son texte tapé, son focus et son
  minuteur. C'est précisément l'endroit où l'on s'assoit et où l'on tape — la
  question de l'agent était inrépondable au clavier pour peu qu'on hésite une
  seconde ;
- **le bloc de raisonnement retient son ouverture**, par le rang du message : le
  fil ne fait que s'allonger, un message déjà écrit ne change plus de place.

Le tableau global de minuteurs disparaît : chaque minuteur vit et meurt avec sa
carte, et n'existait sous cette forme que parce que le fil était rasé.

Et le sondage lui-même devient moins bête : ETag et 304 sur `/transcript`, et
une cadence qui s'étire jusqu'à 5 s quand rien ne bouge, se resserre à 700 ms
dès qu'il se passe quelque chose ou dès qu'on agit — sans ce réveil, on
répondrait à une question puis on attendrait la fin du repli.

Ce que ça ne règle pas, et c'est dit dans le code : l'ETag économise la
sérialisation et le transport, pas la lecture. `forExecution()` rejoue le flux
d'événements à chaque appel. Seul un canal poussé l'éviterait — et pas sur ce
serveur : `php -S` sans `PHP_CLI_SERVER_WORKERS` n'a qu'un processus, une
connexion tenue ouverte bloquerait tout le reste. C'est Mercure, le jour où ce
sera le coût qui gêne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un concentrateur en service compose, port d'hôte fixe (33000) — la raison d'un
hub à part plutôt qu'un endpoint SSE dans l'application tient en une ligne :
`php -S` n'a qu'un processus, une connexion tenue ouverte y bloquerait tout le
reste. Le hub encaisse les connexions, l'application ne fait que poster.

**Une sonnette, pas un colis.** Le message publié dit « cette exécution a
bougé », rien d'autre. La page va chercher le fil par le chemin qu'elle connaît
déjà, et l'ETag rend gratuit le cas où rien n'a changé. Publier le fil aurait
été une seconde projection, calculée une fois par abonné, dans un worker qui n'a
que faire de l'affichage.

**Deux crochets essayés et écartés**, tous deux parce que la démo est sur
Temporal — et c'est le vrai résultat de ce commit :

- `EventStoreInterface::append()` : le cycle de vie du magasin ne se déclenche
  pas sur une application Temporal, `ProjectingEventStore` le documente ;
- `WorkerMessageHandledEvent` : les transports Temporal font le travail *dans*
  leur `get()` et ne rendent **aucune enveloppe**. Pas de message manipulé, donc
  pas d'événement. Le listener écrit pour ça a été retiré plutôt que gardé mort.

Reste le contrôleur, où passe chaque action humaine. Faire porter l'identifiant
d'exécution aux activités de l'agent rendrait la sonnerie possible côté machine,
mais ce serait changer un contrat d'activité pour de l'affichage — et pendant
qu'un tour est en cours la page sonde déjà à 700 ms, parce qu'elle se sait en
attente.

D'où le partage : **Mercure pour ce qui vient d'ailleurs** — un message envoyé
depuis un autre onglet apparaît tout de suite dans les autres —, **le sondage
pour l'exhaustivité**, replié à 15 s, qui rattrape ce que personne ne sonne :
une échéance qui tombe, une clôture sur inactivité, un relais. Hub injoignable,
la page retombe exactement sur le comportement d'avant : le sondage n'est jamais
conditionné à la connexion.

Vérifié contre Temporal : deux sonneries reçues sur le sujet de l'exécution, un
message puis une décision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le signal `alerte` était joignable depuis toujours — n'importe quel service peut
l'envoyer. Ce qui manquait, c'était de savoir **à qui** : `Watch` ne portait
qu'une observation en texte libre, écrite par le modèle. L'agent écrivait
« quand la livraison arrive », l'événement métier disait `commande.expediee`, et
personne ne faisait le rapprochement — sans erreur, sans trace, l'agent dormait
jusqu'à son échéance.

**Le sujet est l'acte de design**, comme `ToolEffect` l'est pour la garde.
`WatchSubject` est un enum, sa liste part au modèle dans le schéma de l'outil, et
un sujet hors liste est **refusé visiblement** : l'agent reçoit le vocabulaire
connu à la place d'un résultat d'outil et reprend la main, au lieu d'armer une
veille que rien ne pourra jamais lever.

**Aucun nouveau magasin.** `WatchRouter` reconstruit l'index à la demande : le
catalogue d'exécutions dit lesquelles tournent, la projection du journal dit ce
que chacune guette. Le journal reste la seule source de vérité, et il n'y a pas
de table de veilles à garder cohérente — donc pas de ligne orpheline le jour où
une exécution meurt sans se désinscrire. C'est un balayage, une lecture de
journal par exécution vivante et par événement ; le commentaire nomme le
plafond et la sortie (une table écrite par une activité, clé d'idempotence
`executionId` + `callId`) sans la construire aujourd'hui.

`AgentSignals` extrait le choix client/bus que le contrôleur portait seul : il a
un second appelant, et deux copies auraient divergé au premier changement. La
sonnerie Mercure y est indissociable du signal — un réveil venu du dehors est
exactement ce pour quoi le concentrateur existe.

`app:agent:evenement` est le levier de la démo. Une commande et pas une route :
c'est la forme qu'une vraie intégration prend, et ça montre ce qu'il faut
montrer — un fait levé **hors de toute requête web** qui réveille un workflow
endormi.

Six tests d'appariement sans Temporal (catalogue bouchonné, InMemoryEventStore,
le vrai AgentSignals avec le MockHub du composant), plus le refus d'un sujet
inconnu. Vérifié en vrai : veille armée sur `commande.expediee`, puis
`app:agent:evenement commande.expediee numero=CMD-42` depuis un terminal —
l'agent se réveille avec les précisions **et** l'intention qu'il avait écrite.

Toutes les veilles du sujet sont réveillées, dans toutes les exécutions : un
fait n'appartient à personne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orité

Première tranche des équipes d'agents. L'invariant d'abord, la mécanique
ensuite : **l'autorité ne grandit pas par délégation.**

Sans lui, déléguer est le chemin d'échappement de la garde : un agent en
`standard` ne peut pas envoyer de courriel, mais il confierait la tâche à un
sous-agent en `auto` qui l'enverrait. La garde ne serait pas contournée par une
faille — elle serait devenue décorative.

**Le plafond vit dans l'enum**, là où son propre docblock plaide que la règle du
mode appartient : `AgentMode::strictest()` et `loosens()`. Le mode effectif d'un
délégué est le plus strict de sa chaîne, à l'entrée **et après** — un sous-agent
qui accepterait `set_mode: auto` n'aurait pas de plafond du tout, et c'est la
moitié qu'on oublie.

Corollaire qui évite une règle en plus : sous un plafond `standard`, un
sous-agent ne peut faire que des lectures — donc rien qui demande une
approbation que personne n'est là pour lui donner. Personne ne regarde un
sous-agent ; il n'a donc le droit de rien d'irréversible, sauf si un humain a
explicitement mis la chaîne en `auto`.

`deleguer(mission, modele)` : workflow enfant, son journal, **son modèle**.
C'est ce qui permet une équipe où chacun a le modèle qui lui va, sans que le
parent sache comment l'autre est fait.

**Un piège du cœur, trouvé en le faisant.** `ChildWorkflowStub::argumentsToInput()`
apparie les arguments **par position** ; PHP passe les arguments nommés à
`__call` dans un tableau à clés de chaînes, aucun indice n'y répond, et *tous*
les paramètres retombent sur leur défaut. Sans exception, sans trace : le
sous-agent démarrait avec un prompt vide et attendait un message qui ne viendrait
jamais. Diagnostiqué dans l'historique Temporal. Contourné ici par un appel
positionnel commenté ; le correctif appartient au cœur et à son propre change.

Six tests, dont le témoin sans lequel ils ne prouveraient rien : le mode est lu
**à travers la garde** — l'outil externe part-il, ou attend-il un accord que
personne ne donnera ? Vérifié en vrai contre Temporal : parent en `standard`,
enfant démarré avec `mode: standard`, `modeCeiling: standard`,
`model: ministral-3b-latest`, et sa réponse relue par le parent.

Hors périmètre, et c'est écrit dans le journal du jour : piloter un pair (régler
son mode, approuver ses outils) — c'est la moitié qui casse l'invariant, si B a
un humain, l'humain de B garde son veto ; la remontée des approbations d'un
descendant vers l'humain de la racine ; et la borne de messages croisés, sans
laquelle deux agents qui se parlent sont une boucle infiniment durable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Depuis que les deux facettes ont chacune leur domaine, plus rien ne menait à
l'agent : on n'y arrivait qu'en tapant l'URL. Un troisième bouton dans le
bandeau, à côté de la documentation et du dashboard.

`path()` s'aperçoit tout seul que l'hôte diffère et rend
`//agent.durable.localhost/durable/chat`. Symétrique du « retour aux samples »
du chat, qui fait le chemin inverse de la même façon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…econstruisaient

Trois tableaux associatifs de la démo d'agent portaient du domaine, pas du fil.

`ChatCompletion` — la forme « chat completions ». `['choices'][0]['message'][…]`
s'écrivait à la main partout où on en avait besoin : le workflow pour la
compaction, la projection trois fois, le modèle scripté pour la fabriquer.
Fabrique de frontière dans les deux sens, donc `ScriptedChatModelClient` écrit
désormais par le type que la projection relit — sans ça les deux dérivaient
ensemble, sans témoin, pendant que le vrai fournisseur ne dérivait pas.

`SettledCalls` — quatre `array<string, true>` (`$executed`, `$decided`,
`$answered`, `$alerted`) que la projection ne consultait que par une condition à
quatre `isset()`. Une cinquième façon de régler un appel voulait dire ne pas
oublier de l'ajouter aux deux endroits.

`Toolset` — la garde voulait une map nom → effet, le toolbox une liste de
schémas, le journal un objet indexé par nom ; chacun la reconstruisait. D'où
`ToolDefinition::listFromWire()`/`listToWire()` et un `effects()` privé dans la
fabrique d'agent, tous trois supprimés. La liste vit dans la collection, les
trois formes sont des méthodes.

Un changement de comportement, pas seulement de forme : la lecture du résumé de
compaction ne gardait `content` que s'il était **déjà une chaîne**. Un modèle qui
raisonne le rend en morceaux `thinking`/`text` — le résumé était alors ignoré, et
la page affichait le fil entier alors que le modèle ne verrait que le résumé.
Tenu par `testAChunkedDigestIsReadLikeAnyOtherAnswer`, qui échoue en affichant le
vieux symptôme si on rétablit la lecture brute.

Vérification : 139 → 145 tests, même profil de passage, aucun test existant
modifié pour accommoder le changement — seules deux constructions de
`ModeToolGuard` suivent la signature. Trois mutations pour prouver que les
nouveaux tests mordent : `finish_reason` figé, raisonnement écrit en morceau
`text`, lecture brute du résumé — les trois sont rattrapées.

Laissés en tableaux, parce qu'ils sont du fil : `Turn::$messages`,
`ToolDefinition::$parameters`, `descendTo()`, les charges de signaux, et les
fixtures de test qui construisent `['choices' => …]` à la main — une fixture qui
passe par le type de production ne peut plus détecter un décalage de forme.

Pas de VO pour la charge de `continueAsNew` : la signature de `run()` est à la
fois un contrat positionnel (`ChildWorkflowStub::argumentsToInput()` apparie par
indice) et un contrat par clés (`dispatchWorkflowRun`). Elle ne peut pas se
replier sur un objet, et un VO à côté ferait trois listes de paramètres au lieu
de deux, pour un seul site d'appel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores documentation/user/use-cases/durable-agent.md as it stood before
761ea76 removed it, and adds what that commit said was missing: a "How to
run it" section, mirroring the Nexus page. The section is what was actually
done to start the demo on this branch: compose stack, symfony serve on 8012
with the workers from .symfony.local.yaml, the host-constrained URLs, the
scripted client as the keyless default.

The two stale claims the removal named are corrected: the hand-written
converter is gone in favour of the Mistral bridge, and reasoning now travels
as thinking parts rather than a reasoning_content the bridge rejects.

The use-cases section itself does not exist on this branch yet; the index
row is for the merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same restoration and the same additions as the English page: the run
section, the corrected provider and reasoning bullets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
APP_HOST_SAMPLES and APP_HOST_AGENT are empty by default: an empty host on a
route is no constraint, so http://localhost:<port>/ serves the samples and
/durable/chat the agent, on any machine. Set both in .env.local to get the
per-facet hosts back. The agent's own `/` goes away: it only existed because
two facets each wanted a root, and the samples root already links to the chat.

Mercure's cors_origins becomes `*`: the page's port is whatever the person
launching chose, and the subscription is anonymous and local. The public URL
says localhost, like the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nywhere

A "What the demo shows" section lists the features with the phrases that
trigger them on the scripted model: guard and modes, questions, watch and
alert, delegation, reasoning, context budget, close and resume, Mercure.

The run section drops the reverse-proxy hosts for http://localhost:8012 and
documents the plain path — compose, two workers, php -S — which was run end
to end. `symfony serve` stays as the shortcut, with what it implies: the
Docker stack lives and dies with it, and workers that lose Temporal exit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts resolved towards main: the timer fix and its test exist on both
sides, main's copy has the English comments; .env.dev takes main's queue
names, the _spike suffix was a workaround for one shared dev database. The
samples index keeps its third button, to the agent demo, in English like
the other two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gplanchat added a commit that referenced this pull request Sep 12, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant