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 000000000..45c16a659 --- /dev/null +++ b/documentation/user/use-cases/durable-agent.fr.md @@ -0,0 +1,236 @@ +--- +title: Un agent IA interruptible +weight: 20 +--- + +# Un agent IA interruptible + +> [!WARNING] +> **Prototype.** Le code décrit ici vit sur la branche `spike/agent-durable-symfony-ai`, qui n'est +> pas fusionnée. Il tourne — voir [Comment on la lance](#comment-on-la-lance) — mais 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. + +## Ce que la démo montre + +Le modèle scripté répond à des mots déclencheurs en français — c'est une doublure, pas un modèle +de langue — donc les phrases ci-dessous sont celles à taper. Un vrai fournisseur décide seul. + +- **Une garde sur chaque appel d'outil.** Chaque outil porte un effet : `read`, `write` ou + `external`. Le mode — `standard`, `edition`, `auto` — dit quels effets passent sans demander ; + le reste suspend le workflow jusqu'à ton accord ou ton refus, avec une échéance d'un quart + d'heure. « Quelle est la météo à Paris ? » est une lecture et passe ; « envoie un mail » est + externe et t'attend. +- **Une question à l'humain**, sous forme de questionnaire sur lequel le workflow attend, comme il + attend une validation : « demande-moi… », ou « lance un import » pour une question que le modèle + pose de lui-même parce que la demande est ambiguë. Ajoute « choix multiple » pour la forme à + plusieurs réponses. +- **Surveiller et alerter.** « surveille la livraison » endort l'agent sur un fait métier. + `php bin/console app:agent:evenement commande.expediee` produit ce fait depuis la ligne de + commande, et l'agent se réveille en sachant ce qu'il faisait et pourquoi. +- **Déléguer.** « délègue… » confie une mission à un sous-agent, qui hérite du mode courant et + n'obtient jamais plus d'autorité que son parent. +- **Le raisonnement dans le fil.** Le raisonnement du modèle traverse la frontière avec la réponse + et s'affiche replié dessous. +- **Un budget de contexte** sur l'appel modèle. Quand la conversation le dépasse, le run la + compacte en résumé et continue ; `?contexte=N` sur l'URL du chat réduit le budget pour le voir + faire. Au bout de quarante tours le run passe la main à un run neuf, pour le coût, pas pour la + taille. +- **Clore et reprendre.** Une conversation close — ou muette pendant une heure — termine son + exécution. Reprendre en ouvre une neuve, qui repart d'un résumé de l'ancienne, lui-même un appel + modèle journalisé. +- **Un canal poussé, facultatif.** Mercure pousse « cette exécution a bougé » ; la page sonde + aussi, donc sans concentrateur elle marche encore, seulement plus lentement. +- Tout cela se lit dans l'UI de Temporal comme les événements d'un seul workflow `Ai_DurableAgent`. + +## 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é. + +## Comment on la lance + +Tout est sous `symfony/` sur la branche. Prérequis : PHP 8.2 avec `ext-grpc`, Composer, Docker. +Aucune clé d'API : le modèle scripté est le défaut. + +```bash +git switch spike/agent-durable-symfony-ai +cd symfony +composer install +docker compose up -d --wait # Postgres, Temporal sur 7234, son UI sur 8089, Mercure sur 33000 +``` + +Puis trois terminaux, depuis le même dossier — les deux workers Temporal sont la démo, et ce sont +des processus séparés parce que leurs long-polls gRPC affament tout transport qui partage leur +boucle : + +```bash +php bin/console messenger:consume durable_temporal_journal +php bin/console messenger:consume durable_temporal_activity +php -S localhost:8012 -t public +``` + +Ouvrir . Les samples sont sur , l'UI de +Temporal sur — chaque conversation y est un workflow `Ai_DurableAgent`. Le +port 8012 est une suggestion ; n'importe quel port libre convient. Si 7234 ou 8089 sont pris, +poser `TEMPORAL_FRONTEND_PORT` et `TEMPORAL_UI_PORT` avant `docker compose up`, et remplacer 7234 +dans les DSN `temporal://` de `.env.dev` et de `config/packages/messenger.yaml`. + +Deux choses qu'on ne devinerait pas : + +- **Un worker qui perd Temporal s'arrête.** Il ne retente pas la connexion ; `docker compose up` + d'abord, les workers ensuite, et un worker arrêté se relance à la main. Sans les workers, un + message est accepté et rien ne bouge. +- **Pour parler à Mistral plutôt qu'au script**, poser `MISTRAL_API_KEY` dans `.env.local` et + lancer `php bin/console cache:clear`. Rien d'autre ne change. + +Avec la CLI Symfony, `symfony serve --port=8012 -d` remplace les trois terminaux : elle démarre les +workers de `.symfony.local.yaml`, et `docker compose up` comme l'un d'eux — ce qui veut dire que la +stack Docker vit et meurt avec le serveur. `symfony server:stop` arrête aussi Temporal, et les +workers avec. Les hôtes `samples.durable.localhost` et `agent.durable.localhost` ne s'activent que +si on le demande : les `APP_HOST_*` de `.env` sont vides par défaut, et c'est ce qui fait marcher +`localhost` partout. + +`docker compose down` arrête la stack. Le journal est dans son volume Postgres ; `down -v` l'oublie. + +## Ce qui n'est pas prouvé + +- **Un vrai fournisseur, peu exercé.** Le convertisseur écrit à la main a disparu : la branche + utilise les normaliseurs et le convertisseur de `symfony/ai-mistral-platform`, et ne remplace que + son client HTTP. Le lancement par défaut ci-dessus ne l'appelle pourtant jamais — c'est le client + scripté qui répond — et rien dans la suite de tests ne l'appelle non plus. Les longues + conversations, les débordements et les reprises ont tous été joués contre le client scripté, pas + contre Mistral. +- **Aucun crash inter-processus scripté.** Le test tourne en mémoire ; le runner y rejoue pour de + vrai. La démo, elle, tourne bien sur plusieurs processus — le serveur web signale, un worker + appelle le modèle, un autre fait tourner le workflow — mais en tuer un au milieu d'une + conversation et le voir reprendre, c'est un geste à la main, pas un test de la suite. +- **Les blocs de raisonnement passent, mais la signature reste dehors.** Le premier jet + journalisait le raisonnement dans un champ `reasoning_content` à côté du message, et le pont + Mistral refuse cette forme par un 422. Le raisonnement voyage maintenant en morceaux `thinking` + dans `content`, qui est la forme de Mistral, et la lecture accepte les deux formes. + + 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` 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 pas vérifié ici. + + **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 new file mode 100644 index 000000000..09849ee1c --- /dev/null +++ b/documentation/user/use-cases/durable-agent.md @@ -0,0 +1,225 @@ +--- +title: An interruptible AI agent +weight: 20 +--- + +# An interruptible AI agent + +> [!WARNING] +> **Prototype.** The code described here lives on the `spike/agent-durable-symfony-ai` branch, +> which has not been merged. It runs — see [How to run it](#how-to-run-it) — but 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. + +## What the demo shows + +The scripted model answers to French trigger words — it is a stand-in, not a language model — so +the phrases below are the ones to type. A real provider decides on its own. + +- **A guard on every tool call.** Each tool carries an effect: `read`, `write` or `external`. The + mode — `standard`, `edition`, `auto` — says which effects pass without asking; anything else + suspends the workflow until you approve or refuse, with a fifteen-minute deadline. « Quelle est + la météo à Paris ? » is a read and passes; « envoie un mail » is external and waits for you. +- **A question to the human**, as a questionnaire the workflow waits on, the same way it waits for + an approval: « demande-moi… », or « lance un import » for a question the model asks on its own + because the request is ambiguous. Add « choix multiple » for the multi-select form. +- **Watch and alert.** « surveille la livraison » puts the agent to sleep on a business fact. + `php bin/console app:agent:evenement commande.expediee` raises that fact from the CLI, and the + agent wakes up knowing what it was doing and why. +- **Delegation.** « délègue… » hands a mission to a sub-agent, which inherits the current mode + and never gets more authority than its parent. +- **Reasoning in the thread.** The model's reasoning crosses the boundary with the answer and + shows folded under it. +- **A context budget** on the model call. When the conversation outgrows it, the run compacts it + into a summary and continues; `?contexte=N` on the chat URL shrinks the budget so you can watch + it happen. After forty turns the run hands over to a fresh one for cost, not for size. +- **Close and resume.** A closed conversation — or one silent for an hour — ends its execution. + Resuming opens a new execution that starts from a summary of the old one, which is itself a + journaled model call. +- **A push channel that is optional.** Mercure pushes « this execution moved »; the page also + polls, so without the hub it still works, only slower. +- Every one of these is visible in Temporal's UI as events of one `Ai_DurableAgent` workflow. + +## 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. + +## How to run it + +Everything lives under `symfony/` on the branch. Prerequisites: PHP 8.2 with `ext-grpc`, Composer, +Docker. No API key: the scripted model is the default. + +```bash +git switch spike/agent-durable-symfony-ai +cd symfony +composer install +docker compose up -d --wait # Postgres, Temporal on 7234, its UI on 8089, Mercure on 33000 +``` + +Then three terminals, from the same directory — the two Temporal workers are the demo, and they +are separate processes because their gRPC long-polls starve any transport sharing their loop: + +```bash +php bin/console messenger:consume durable_temporal_journal +php bin/console messenger:consume durable_temporal_activity +php -S localhost:8012 -t public +``` + +Open . The samples are at , Temporal's UI +at — each conversation is one `Ai_DurableAgent` workflow there. Port 8012 is +a suggestion; any free port does. If 7234 or 8089 are taken, set `TEMPORAL_FRONTEND_PORT` and +`TEMPORAL_UI_PORT` before `docker compose up`, and replace 7234 in the `temporal://` DSNs of +`.env.dev` and `config/packages/messenger.yaml`. + +Two things you would not guess: + +- **A worker that loses Temporal exits.** It does not retry the connection; `docker compose up` + first, workers second, and a worker that stopped is started again by hand. Without the workers, + a message is accepted and nothing moves. +- **To talk to Mistral instead of the script**, put `MISTRAL_API_KEY` in `.env.local` and run + `php bin/console cache:clear`. Nothing else changes. + +With the Symfony CLI, `symfony serve --port=8012 -d` replaces the three terminals: it starts the +workers from `.symfony.local.yaml`, and `docker compose up` as one of them — which means the +Docker stack now lives and dies with the server. `symfony server:stop` stops Temporal too, and +the workers with it. It also sets the hosts to `samples.durable.localhost` and +`agent.durable.localhost` only if you ask: `APP_HOST_*` in `.env` are empty by default, which is +what makes `localhost` work everywhere. + +`docker compose down` stops the stack. The journal is in its Postgres volume; `down -v` forgets it. + +## What is not proven + +- **One real provider, exercised little.** The hand-written converter is gone: the branch uses + `symfony/ai-mistral-platform`'s normalizers and result converter, and only replaces its HTTP + client. The default run above never calls it, though — the scripted client answers — and nothing + in the test suite does either. The long conversations, the overflows and the resumes have all been + run against the scripted client, not against Mistral. +- **No scripted cross-process crash.** The test runs in memory; the runner replays for real there. + The demo does run across processes — the web server signals, a worker calls the model, another + runs the workflow — but killing one mid-conversation and watching it resume is something you do + by hand, not something the suite does. +- **Reasoning blocks travel; the signature does not.** The first draft journaled reasoning as a + `reasoning_content` field next to the message, and the Mistral bridge refuses that with a 422. + Reasoning now travels as `thinking` parts inside `content`, which is Mistral's form, and the + reader accepts both shapes. + + 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` never reads `getSignature()`. Signed + replay therefore assumes a provider bridge replacing that normalizer — plausible, that is what + `Contract` is for, but not verified here. + + **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. diff --git a/symfony/.env b/symfony/.env index 80a95337d..248c31fbc 100644 --- a/symfony/.env +++ b/symfony/.env @@ -39,4 +39,31 @@ TEMPORAL_UI_PORT=8089 ###> doctrine/doctrine-bundle ### # SQLite default: parameters env(DATABASE_URL) in config/packages/doctrine.yaml # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" +DATABASE_URL="sqlite:///%kernel.project_dir%/var/durable.db" + +###> symfony/ai-mistral-platform ### +# Vide : la fabrique rend alors le client scripté, et la démo tourne sans réseau ni clé. +# Renseigner dans .env.local, qui n'est pas suivi par git. +MISTRAL_API_KEY= +###< symfony/ai-mistral-platform ### ###< doctrine/doctrine-bundle ### + +###> démo : un domaine par facette, en option ### +# Vides, les deux facettes — les samples et le tableau de bord, l'agent — répondent sur le même +# hôte, et http://localhost:/ suffit : c'est le défaut, celui qui marche sur n'importe quel +# poste. Renseignés dans .env.local, le routage distingue les facettes par l'hôte, et un lien de +# l'une vers l'autre sort en URL absolue tout seul — Symfony voit que l'hôte diffère. Par exemple, +# derrière un reverse proxy local : +# APP_HOST_SAMPLES=samples.durable.localhost +# APP_HOST_AGENT=agent.durable.localhost +APP_HOST_SAMPLES= +APP_HOST_AGENT= +###< démo : un domaine par facette, en option ### + +###> symfony/mercure-bundle ### +# Le concentrateur est un service compose ; voir compose.override.yaml pour le port et les +# directives. Les deux URL sont identiques tant que l'application tourne sur l'hôte. +MERCURE_URL=http://localhost:33000/.well-known/mercure +MERCURE_PUBLIC_URL=http://localhost:33000/.well-known/mercure +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +###< symfony/mercure-bundle ### diff --git a/symfony/.env.test b/symfony/.env.test index 72bf6d2f6..56624e1f5 100644 --- a/symfony/.env.test +++ b/symfony/.env.test @@ -2,3 +2,9 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' DEFAULT_URI=http://localhost + +# Les contraintes d'hôte restent actives en test — les neutraliser ferait passer des routes que le +# navigateur ne verrait jamais. `DEFAULT_URI` fixe l'hôte du client à `localhost`, qui est donc +# celui des samples ; l'agent a le sien, et c'est bien ainsi que les deux se distinguent en vrai. +APP_HOST_SAMPLES=localhost +APP_HOST_AGENT=agent.localhost diff --git a/symfony/bin/spike-workers.sh b/symfony/bin/spike-workers.sh new file mode 100755 index 000000000..e55d47b1c --- /dev/null +++ b/symfony/bin/spike-workers.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Les deux workers Temporal dont la démo d'agent a besoin (spike/agent-durable-symfony-ai). +# +# ./bin/spike-workers.sh démarre les deux en tâche de fond +# pgrep -f messenger:consume vérifie qu'ils tournent +# +# Deux workers séparés, pas un seul : les transports Temporal sont des long-polls gRPC et +# affament tout transport qui partage leur boucle. +# +# Les valeurs d'environnement sont requotées : `symfony var:export` rend DATABASE_URL avec ses +# `&` nus, que `eval` coupe en tâches de fond — l'URL arrivait tronquée à `sslmode=disable`. +set -u +cd "$(dirname "$0")/.." || exit 1 + +eval "$(symfony var:export --multiline 2>/dev/null | sed "s/^export \([A-Za-z_][A-Za-z0-9_]*\)=\(.*\)$/export \1='\2'/")" + +for transport in durable_temporal_journal durable_temporal_activity; do + if pgrep -f "messenger:consume $transport" > /dev/null; then + echo "déjà en cours : $transport" + continue + fi + # setsid + nohup : sans détachement, les workers meurent avec le shell qui les a lancés. + setsid nohup php bin/console messenger:consume "$transport" > "var/log/$transport.log" 2>&1 < /dev/null & + disown + echo "démarré : $transport (var/log/$transport.log)" +done diff --git a/symfony/compose.override.yaml b/symfony/compose.override.yaml index c5612b0ad..bed6915b5 100644 --- a/symfony/compose.override.yaml +++ b/symfony/compose.override.yaml @@ -1,7 +1,42 @@ - services: ###> doctrine/doctrine-bundle ### database: ports: - - "5432" + # Port fixe : `symfony serve` relance `docker compose up`, et un port éphémère change à + # chaque redémarrage — le serveur web garde alors l'ancien et tombe en « connection refused ». + - "35432:5432" ###< doctrine/doctrine-bundle ### + + # Le concentrateur Mercure. Il existe pour une raison précise : en PHP, tenir une connexion + # ouverte par onglet coûte un processus par onglet — `php -S` n'en a qu'un, et la démo gèlerait + # au premier abonné. Un hub à côté encaisse les connexions ; l'application ne fait que poster. + # + # Port d'hôte fixe, pour la même raison que la base : `symfony serve` relance `docker compose + # up`, et un port éphémère laisserait le navigateur sur l'ancien. + # + # L'application et les workers tournent sur l'hôte, pas dans le réseau compose : ils postent sur + # 127.0.0.1 comme le navigateur. D'où une seule URL pour les deux, ce qui n'est vrai que d'une + # démo lancée par `symfony serve`. + mercure: + image: dunglas/mercure:v0.16 + restart: unless-stopped + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + # `anonymous` : les sujets sont des identifiants d'exécution, la démo est locale, et un JWT + # d'abonnement par page n'apprendrait rien à personne ici. Sur autre chose qu'une démo, c'est + # un abonnement signé et rien d'autre. + # `cors_origins` : la page est sur localhost:, le hub sur localhost:33000 — deux + # origines, et le port de la page est au choix de qui lance. Sans cette ligne, `EventSource` + # échoue **en silence** et se contente de retenter. `*` parce que l'abonnement est anonyme + # et local ; ailleurs qu'en démo, c'est la liste des origines et un abonnement signé. + MERCURE_EXTRA_DIRECTIVES: | + anonymous + cors_origins * + ports: + - "33000:80" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/healthz"] + timeout: 5s + retries: 5 diff --git a/symfony/composer.json b/symfony/composer.json index 30b3301fe..b496836d6 100644 --- a/symfony/composer.json +++ b/symfony/composer.json @@ -18,6 +18,13 @@ "symlink": true } }, + { + "type": "path", + "url": "../src/Bridge/Dbal", + "options": { + "symlink": true + } + }, { "type": "path", "url": "../src/Bridge/Temporal", @@ -39,17 +46,22 @@ "ext-iconv": "*", "doctrine/doctrine-bundle": ">=2.19.0", "gplanchat/durable": "@dev", + "gplanchat/durable-bridge-dbal": "@dev", "gplanchat/durable-bridge-temporal": "@dev", "gplanchat/durable-bundle": "@dev", "gplanchat/durable-demo-contracts": "@dev", + "symfony/ai-agent": "v0.13.0", + "symfony/ai-mistral-platform": "v0.13.0", "symfony/console": "^7.4.17 || ^8.0", "symfony/doctrine-messenger": "^7.4.15 || ^8.0", "symfony/dotenv": "^7.4.15 || ^8.0", "symfony/flex": "^2.11", "symfony/framework-bundle": "^7.4.17 || ^8.0", + "symfony/lock": "^7.4", "symfony/runtime": "^7.4.14 || ^8.0", "symfony/uid": "^7.4.17 || ^8.0", - "symfony/yaml": "^7.4.17 || ^8.0" + "symfony/yaml": "^7.4.17 || ^8.0", + "symfony/mercure-bundle": "^0.5" }, "config": { "allow-plugins": { diff --git a/symfony/composer.lock b/symfony/composer.lock index f0e71da78..ed43e878e 100644 --- a/symfony/composer.lock +++ b/symfony/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e789f700233e48e2df173d4a3a19f3f2", + "content-hash": "5423770ba27a055c679643964f117264", "packages": [ { "name": "doctrine/dbal", @@ -633,7 +633,7 @@ "dist": { "type": "path", "url": "../src/Durable", - "reference": "fbf41ee943e176704562b50cefb8bbfcc20ba2a1" + "reference": "974da67c472852a504bbde1db9a481a7e56e0044" }, "require": { "php": ">=8.2", @@ -669,6 +669,66 @@ } ], "description": "PHP library for durable execution: workflows, activities, event journal, and transports (HttpKernel integration lives in the bundle).", + "keywords": [ + "activity", + "durable-execution", + "event-sourcing", + "fiber", + "long-running", + "orchestration", + "saga", + "workflow" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "gplanchat/durable-bridge-dbal", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../src/Bridge/Dbal", + "reference": "d9f1ad1223332831e888538fda3c19cda1f0a236" + }, + "require": { + "doctrine/dbal": "^3.7 || ^4.0", + "gplanchat/durable": "self.version", + "php": ">=8.2", + "symfony/lock": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^6.4 || ^7.0 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gplanchat\\Bridge\\Dbal\\": "./" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégory Planchat", + "email": "gregory@gyroscops.com" + } + ], + "description": "Doctrine DBAL journal, metadata and parent-link stores for gplanchat/durable — durable execution on a single SQL database, no orchestrator cluster", + "keywords": [ + "doctrine", + "doctrine-dbal", + "durable-execution", + "event-store", + "long-running", + "orchestration", + "workflow" + ], "transport-options": { "symlink": true, "relative": true @@ -680,7 +740,7 @@ "dist": { "type": "path", "url": "../src/Bridge/Temporal", - "reference": "86199f26705baaca45decac9c54daba3406e0f3d" + "reference": "311d368524add9eeaf3834c3112584de72b405d3" }, "require": { "ext-grpc": "*", @@ -717,6 +777,16 @@ } ], "description": "Temporal journal EventStore + worker (gRPC only, no Temporal PHP SDK) for gplanchat/durable", + "keywords": [ + "durable-execution", + "grpc", + "long-running", + "nexus", + "orchestration", + "temporal", + "temporalio", + "workflow" + ], "transport-options": { "symlink": true, "relative": true @@ -728,7 +798,7 @@ "dist": { "type": "path", "url": "../src/DurableBundle", - "reference": "6fe58d8bb99c8afc5cff1d1fb098d93af725d540" + "reference": "3a0012dfbf1d10c4e253171ba1a3574de09d9061" }, "require": { "gplanchat/durable": "self.version", @@ -738,18 +808,15 @@ "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", "symfony/messenger": "^6.4 || ^7.0 || ^8.0", "symfony/service-contracts": "^3.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0" }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, "suggest": { "phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)", + "symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase", "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)" }, "type": "symfony-bundle", @@ -773,6 +840,16 @@ } ], "description": "Symfony bundle for gplanchat/durable: extension, autoconfiguration of workflows and activities.", + "keywords": [ + "durable-execution", + "long-running", + "messenger", + "orchestration", + "saga", + "symfony", + "symfony-bundle", + "workflow" + ], "transport-options": { "symlink": true, "relative": true @@ -855,6 +932,354 @@ }, "time": "2026-07-03T09:39:53+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "oskarstark/enum-helper", + "version": "1.8.4", + "source": { + "type": "git", + "url": "https://github.com/OskarStark/enum-helper.git", + "reference": "14e185f1cc259d7cd3f61eea17f9b174a886a6da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OskarStark/enum-helper/zipball/14e185f1cc259d7cd3f61eea17f9b174a886a6da", + "reference": "14e185f1cc259d7cd3f61eea17f9b174a886a6da", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "phpunit/phpunit": "<10" + }, + "require-dev": { + "ergebnis/php-cs-fixer-config": "^6.58", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "OskarStark\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "description": "This library provides helpers for several enum operations", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/OskarStark/enum-helper/issues", + "source": "https://github.com/OskarStark/enum-helper/tree/1.8.4" + }, + "time": "2026-02-05T08:59:09+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1056,23 +1481,79 @@ "time": "2019-01-08T18:20:26+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "psr/link", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/php-fig/link.git", + "reference": "84b159194ecfd7eaa472280213976e96415433f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7", + "reference": "84b159194ecfd7eaa472280213976e96415433f7", "shasum": "" }, "require": { "php": ">=8.0.0" }, - "type": "library", + "suggest": { + "fig/link-util": "Provides some useful PSR-13 utilities" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Link\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for HTTP links", + "homepage": "https://github.com/php-fig/link", + "keywords": [ + "http", + "http-link", + "link", + "psr", + "psr-13", + "rest" + ], + "support": { + "source": "https://github.com/php-fig/link/tree/2.0.1" + }, + "time": "2021-03-11T23:00:27+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", "extra": { "branch-alias": { "dev-master": "3.x-dev" @@ -1106,64 +1587,56 @@ "time": "2024-09-11T13:17:53+00:00" }, { - "name": "symfony/cache", - "version": "v7.4.17", + "name": "symfony/ai-agent", + "version": "v0.13.0", "source": { "type": "git", - "url": "https://github.com/symfony/cache.git", - "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938" + "url": "https://github.com/symfony/ai-agent.git", + "reference": "5a6b80c638ecba5949a40145b90a0b6804565369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/f6028442dd1dfa4f88e9c7360d753948d165e938", - "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938", + "url": "https://api.github.com/repos/symfony/ai-agent/zipball/5a6b80c638ecba5949a40145b90a0b6804565369", + "reference": "5a6b80c638ecba5949a40145b90a0b6804565369", "shasum": "" }, "require": { + "ext-fileinfo": "*", "php": ">=8.2", - "psr/cache": "^2.0|^3.0", - "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^3.6", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.4|^7.0|^8.0" - }, - "conflict": { - "doctrine/dbal": "<3.6", - "ext-relay": "<0.12.1", - "symfony/dependency-injection": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/var-dumper": "<6.4" - }, - "provide": { - "psr/cache-implementation": "2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0", - "symfony/cache-implementation": "1.1|2.0|3.0" + "phpdocumentor/reflection-docblock": "^5.4|^6.0", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/ai-platform": "^0.13", + "symfony/clock": "^7.3|^8.0", + "symfony/http-client": "^7.3|^8.0", + "symfony/polyfill-php85": "^1.33", + "symfony/property-access": "^7.3|^8.0", + "symfony/property-info": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", + "symfony/type-info": "^7.3|^8.0" }, "require-dev": { - "cache/integration-tests": "^1.0.3", - "doctrine/dbal": "^3.6|^4", - "predis/predis": "^1.1|^2.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53", + "symfony/ai-store": "^0.13", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/translation-contracts": "^3.6", + "symfony/validator": "^7.3|^8.0" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Cache\\": "" - }, - "classmap": [ - "Traits/ValueWrapper.php" - ], - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\AI\\Agent\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1171,22 +1644,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", - "homepage": "https://symfony.com", + "description": "PHP library for building agentic applications.", "keywords": [ - "caching", - "psr6" + "Agent", + "ai", + "llm" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.17" + "source": "https://github.com/symfony/ai-agent/tree/v0.13.0" }, "funding": [ { @@ -1206,39 +1683,43 @@ "type": "tidelift" } ], - "time": "2026-08-19T08:28:05+00:00" + "time": "2026-08-30T01:03:45+00:00" }, { - "name": "symfony/cache-contracts", - "version": "v3.7.1", + "name": "symfony/ai-generic-platform", + "version": "v0.13.0", "source": { "type": "git", - "url": "https://github.com/symfony/cache-contracts.git", - "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + "url": "https://github.com/symfony/ai-generic-platform.git", + "reference": "6346887947678df84c67d1d8139aff7b8a5dda4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", - "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/6346887947678df84c67d1d8139aff7b8a5dda4a", + "reference": "6346887947678df84c67d1d8139aff7b8a5dda4a", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/cache": "^3.0" + "php": ">=8.2", + "symfony/ai-platform": "^0.13", + "symfony/http-client": "^7.3|^8.0" }, - "type": "library", + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53" + }, + "type": "symfony-ai-platform", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Cache\\": "" + "Symfony\\AI\\Platform\\Bridge\\Generic\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1247,26 +1728,27 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to caching", - "homepage": "https://symfony.com", + "description": "Generic platform bridge for Symfony AI", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "Bridge", + "ai", + "generic", + "platform" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/ai-generic-platform/tree/v0.13.0" }, "funding": [ { @@ -1286,41 +1768,45 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-30T01:03:45+00:00" }, { - "name": "symfony/clock", - "version": "v7.4.8", + "name": "symfony/ai-mistral-platform", + "version": "v0.13.0", "source": { "type": "git", - "url": "https://github.com/symfony/clock.git", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + "url": "https://github.com/symfony/ai-mistral-platform.git", + "reference": "1136015dc36b8bc77143453c729e2f13f326abc2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", - "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "url": "https://api.github.com/repos/symfony/ai-mistral-platform/zipball/1136015dc36b8bc77143453c729e2f13f326abc2", + "reference": "1136015dc36b8bc77143453c729e2f13f326abc2", "shasum": "" }, "require": { "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "symfony/ai-generic-platform": "^0.13", + "symfony/ai-platform": "^0.13", + "symfony/http-client": "^7.3|^8.0" }, - "provide": { - "psr/clock-implementation": "1.0" + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53" + }, + "type": "symfony-ai-platform", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } }, - "type": "library", "autoload": { - "files": [ - "Resources/now.php" - ], "psr-4": { - "Symfony\\Component\\Clock\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\AI\\Platform\\Bridge\\Mistral\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1328,23 +1814,27 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Decouples applications from the system clock", - "homepage": "https://symfony.com", + "description": "Mistral platform bridge for Symfony AI", "keywords": [ - "clock", - "psr20", - "time" + "Bridge", + "ai", + "mistral", + "platform" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.8" + "source": "https://github.com/symfony/ai-mistral-platform/tree/v0.13.0" }, "funding": [ { @@ -1364,47 +1854,66 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-08-30T01:03:45+00:00" }, { - "name": "symfony/config", - "version": "v7.4.17", + "name": "symfony/ai-platform", + "version": "v0.13.0", "source": { "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" + "url": "https://github.com/symfony/ai-platform.git", + "reference": "bfc85585ef84d3cb23c8ea01eee2ec1fb8ead1a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", - "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", + "url": "https://api.github.com/repos/symfony/ai-platform/zipball/bfc85585ef84d3cb23c8ea01eee2ec1fb8ead1a4", + "reference": "bfc85585ef84d3cb23c8ea01eee2ec1fb8ead1a4", "shasum": "" }, "require": { + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.1|^8.0", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/finder": "<6.4", - "symfony/service-contracts": "<2.5" + "phpdocumentor/reflection-docblock": "^5.4|^6.0", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/clock": "^7.3|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/property-access": "^7.3|^8.0", + "symfony/property-info": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", + "symfony/type-info": "^7.3|^8.0", + "symfony/uid": "^7.3|^8.0" }, "require-dev": { - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.53", + "symfony/cache": "^7.3|^8.0", + "symfony/console": "^7.3|^8.0", + "symfony/dotenv": "^7.3|^8.0", + "symfony/expression-language": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0", + "symfony/http-client": "^7.3|^8.0", + "symfony/http-client-contracts": "^3.5", + "symfony/intl": "^7.3|^8.0", + "symfony/process": "^7.3|^8.0", + "symfony/validator": "^7.3|^8.0", + "symfony/var-dumper": "^7.3|^8.0", + "symfony/yaml": "^7.3|^8.0" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Config\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\AI\\Platform\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1412,18 +1921,52 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", - "homepage": "https://symfony.com", + "description": "PHP library for interacting with AI platform provider.", + "keywords": [ + "Gemini", + "OpenRouter", + "ai", + "aimlapi", + "albert", + "amazeeai", + "anthropic", + "azure", + "bedrock", + "cerebras", + "dockermodelrunner", + "elevenlabs", + "huggingface", + "inference", + "litellm", + "llama", + "lmstudio", + "meta", + "mistral", + "nova", + "ollama", + "openai", + "ovh", + "perplexity", + "replicate", + "speech", + "transformers", + "vertexai", + "voyage" + ], "support": { - "source": "https://github.com/symfony/config/tree/v7.4.17" + "source": "https://github.com/symfony/ai-platform/tree/v0.13.0" }, "funding": [ { @@ -1443,58 +1986,65 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:55:18+00:00" + "time": "2026-08-30T02:33:27+00:00" }, { - "name": "symfony/console", + "name": "symfony/cache", "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" + "url": "https://github.com/symfony/cache.git", + "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", + "url": "https://api.github.com/repos/symfony/cache/zipball/f6028442dd1dfa4f88e9c7360d753948d165e938", + "reference": "f6028442dd1dfa4f88e9c7360d753948d165e938", "shasum": "" }, "require": { "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "conflict": { + "doctrine/dbal": "<3.6", + "ext-relay": "<0.12.1", "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" }, "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2|^3", + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/config": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" + "Symfony\\Component\\Cache\\": "" }, - "exclude-from-classmap": [ + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ "/Tests/" ] }, @@ -1504,24 +2054,22 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", "homepage": "https://symfony.com", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "caching", + "psr6" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.17" + "source": "https://github.com/symfony/cache/tree/v7.4.17" }, "funding": [ { @@ -1541,52 +2089,40 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-19T08:28:05+00:00" }, { - "name": "symfony/dependency-injection", - "version": "v7.4.17", + "name": "symfony/cache-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0" + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", - "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" - }, - "conflict": { - "ext-psr": "<1.1|>=2", - "symfony/config": "<6.4", - "symfony/finder": "<6.4", - "symfony/yaml": "<6.4" - }, - "provide": { - "psr/container-implementation": "1.1|2.0", - "symfony/service-implementation": "1.1|2.0|3.0" - }, - "require-dev": { - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "php": ">=8.1", + "psr/cache": "^3.0" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\DependencyInjection\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\Cache\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1594,18 +2130,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "description": "Generic abstractions related to caching", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.17" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -1625,38 +2169,40 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "symfony/clock", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "provide": { + "psr/clock-implementation": "1.0" }, + "type": "library", "autoload": { "files": [ - "function.php" + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1673,10 +2219,15 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Decouples applications from the system clock", "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -1696,77 +2247,43 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { - "name": "symfony/doctrine-bridge", + "name": "symfony/config", "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "577bded9ac6dbd6ec83fd269362dd85ef7c82bda" + "url": "https://github.com/symfony/config.git", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/577bded9ac6dbd6ec83fd269362dd85ef7c82bda", - "reference": "577bded9ac6dbd6ec83fd269362dd85ef7c82bda", + "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", "shasum": "" }, "require": { - "doctrine/event-manager": "^2", - "doctrine/persistence": "^3.1|^4", "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3" + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "doctrine/collections": "<1.8", - "doctrine/dbal": "<3.6", - "doctrine/lexer": "<1.1", - "doctrine/orm": "<2.15", - "symfony/cache": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/form": "<6.4.6|>=7,<7.0.6", - "symfony/http-foundation": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/lock": "<6.4", - "symfony/messenger": "<6.4", - "symfony/property-info": "<6.4", - "symfony/security-bundle": "<6.4", - "symfony/security-core": "<6.4", - "symfony/validator": "<7.4.17|>=8.0,<8.1.5" + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" }, "require-dev": { - "doctrine/collections": "^1.8|^2.0", - "doctrine/data-fixtures": "^1.1|^2", - "doctrine/dbal": "^3.6|^4", - "doctrine/orm": "^2.15|^3", - "psr/log": "^1|^2|^3", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/form": "^7.2|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", - "symfony/type-info": "^7.1.8|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" }, - "type": "symfony-bridge", + "type": "library", "autoload": { "psr-4": { - "Symfony\\Bridge\\Doctrine\\": "" + "Symfony\\Component\\Config\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1786,10 +2303,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides integration for Doctrine with various Symfony components", + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.17" + "source": "https://github.com/symfony/config/tree/v7.4.17" }, "funding": [ { @@ -1809,40 +2326,56 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:38:51+00:00" + "time": "2026-08-20T09:55:18+00:00" }, { - "name": "symfony/doctrine-messenger", - "version": "v7.4.15", + "name": "symfony/console", + "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "2356265da4dd8e8b689e735dcce33f5fd40506e5" + "url": "https://github.com/symfony/console.git", + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/2356265da4dd8e8b689e735dcce33f5fd40506e5", - "reference": "2356265da4dd8e8b689e735dcce33f5fd40506e5", + "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", + "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", "shasum": "" }, "require": { - "doctrine/dbal": "^3.6|^4", "php": ">=8.2", - "symfony/messenger": "^7.2|^8.0", - "symfony/service-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" }, "conflict": { - "doctrine/persistence": "<1.3" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "doctrine/persistence": "^1.3|^2|^3", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, - "type": "symfony-messenger-bridge", + "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + "Symfony\\Component\\Console\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1862,11 +2395,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Doctrine Messenger Bridge", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.15" - }, + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.17" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -1885,37 +2424,48 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:18:55+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { - "name": "symfony/dotenv", - "version": "v7.4.15", + "name": "symfony/dependency-injection", + "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/dotenv.git", - "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4" + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/99b5b14953237c89ed24f1af5ba34225caf598a4", - "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { - "symfony/console": "<6.4", - "symfony/process": "<6.4" + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Dotenv\\": "" + "Symfony\\Component\\DependencyInjection\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1935,15 +2485,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Registers environment variables from a .env file", + "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", - "keywords": [ - "dotenv", - "env", - "environment" - ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.15" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.17" }, "funding": [ { @@ -1963,49 +2508,38 @@ "type": "tidelift" } ], - "time": "2026-07-26T10:22:28+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { - "name": "symfony/error-handler", - "version": "v7.4.17", + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/error-handler.git", - "reference": "8373921e231e190a88e2ad526951bbaa791576fa" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", - "reference": "8373921e231e190a88e2ad526951bbaa791576fa", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" - }, - "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/webpack-encore-bundle": "^1.0|^2.0" + "php": ">=8.1" }, - "bin": [ - "Resources/bin/patch-type-declarations" - ], "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\ErrorHandler\\": "" + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" }, - "exclude-from-classmap": [ - "/Tests/" + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2014,18 +2548,18 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to manage errors and ease debugging PHP code", + "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.17" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -2045,49 +2579,77 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/event-dispatcher", + "name": "symfony/doctrine-bridge", "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "577bded9ac6dbd6ec83fd269362dd85ef7c82bda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", - "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/577bded9ac6dbd6ec83fd269362dd85ef7c82bda", + "reference": "577bded9ac6dbd6ec83fd269362dd85ef7c82bda", "shasum": "" }, "require": { + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", "php": ">=8.2", - "symfony/event-dispatcher-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.6", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<2.15", + "symfony/cache": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "symfony/form": "<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/messenger": "<6.4", + "symfony/property-info": "<6.4", + "symfony/security-bundle": "<6.4", + "symfony/security-core": "<6.4", + "symfony/validator": "<7.4.17|>=8.0,<8.1.5" }, "require-dev": { + "doctrine/collections": "^1.8|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^3.6|^4", + "doctrine/orm": "^2.15|^3", "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", "symfony/config": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/form": "^7.2|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, - "type": "library", + "type": "symfony-bridge", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Bridge\\Doctrine\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2107,10 +2669,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.17" }, "funding": [ { @@ -2130,40 +2692,44 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-08-21T19:38:51+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.1", + "name": "symfony/doctrine-messenger", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "2356265da4dd8e8b689e735dcce33f5fd40506e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/2356265da4dd8e8b689e735dcce33f5fd40506e5", + "reference": "2356265da4dd8e8b689e735dcce33f5fd40506e5", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "doctrine/dbal": "^3.6|^4", + "php": ">=8.2", + "symfony/messenger": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" }, + "type": "symfony-messenger-bridge", "autoload": { "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2171,26 +2737,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.15" }, "funding": [ { @@ -2210,34 +2768,37 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-07-29T06:18:55+00:00" }, { - "name": "symfony/filesystem", - "version": "v7.4.17", + "name": "symfony/dotenv", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d" + "url": "https://github.com/symfony/dotenv.git", + "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", - "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/99b5b14953237c89ed24f1af5ba34225caf598a4", + "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "php": ">=8.2" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/process": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Component\\Dotenv\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2257,10 +2818,15 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Registers environment variables from a .env file", "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.17" + "source": "https://github.com/symfony/dotenv/tree/v7.4.15" }, "funding": [ { @@ -2280,32 +2846,46 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-07-26T10:22:28+00:00" }, { - "name": "symfony/finder", + "name": "symfony/error-handler", "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + "url": "https://github.com/symfony/error-handler.git", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\ErrorHandler\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2325,10 +2905,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.17" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -2348,45 +2928,53 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { - "name": "symfony/flex", - "version": "v2.11.0", + "name": "symfony/event-dispatcher", + "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/flex.git", - "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", - "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { - "composer-plugin-api": "^2.1", - "php": ">=8.1" + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "composer/semver": "<1.7.2", - "symfony/dotenv": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" }, - "require-dev": { - "composer/composer": "^2.1", - "phpunit/phpunit": "^12.4", - "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", - "symfony/filesystem": "^6.4|^7.4|^8.0", - "symfony/process": "^6.4|^7.4|^8.0" + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, - "type": "composer-plugin", - "extra": { - "class": "Symfony\\Flex\\Flex" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Flex\\": "src" - } + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2395,13 +2983,17 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien.potencier@gmail.com" + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Composer plugin for Symfony", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.11.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -2421,126 +3013,40 @@ "type": "tidelift" } ], - "time": "2026-05-29T17:25:22+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { - "name": "symfony/framework-bundle", - "version": "v7.4.17", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/framework-bundle.git", - "reference": "c7f3c14c51fccba466761e8c0e7dafe4335acc16" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c7f3c14c51fccba466761e8c0e7dafe4335acc16", - "reference": "c7f3c14c51fccba466761e8c0e7dafe4335acc16", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { - "composer-runtime-api": ">=2.1", - "ext-xml": "*", - "php": ">=8.2", - "symfony/cache": "^6.4.12|^7.0|^8.0", - "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.3|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^7.1|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php85": "^1.32", - "symfony/routing": "^7.4|^8.0" - }, - "conflict": { - "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/asset": "<6.4", - "symfony/asset-mapper": "<6.4", - "symfony/clock": "<6.4", - "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", - "symfony/dom-crawler": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/form": "<7.4", - "symfony/http-client": "<6.4", - "symfony/lock": "<6.4", - "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", - "symfony/messenger": "<7.4", - "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", - "symfony/property-access": "<6.4", - "symfony/property-info": "<6.4", - "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", - "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", - "symfony/security-core": "<6.4", - "symfony/security-csrf": "<7.2", - "symfony/serializer": "<7.2.5", - "symfony/stopwatch": "<6.4", - "symfony/translation": "<7.3", - "symfony/twig-bridge": "<6.4", - "symfony/twig-bundle": "<6.4", - "symfony/validator": "<6.4", - "symfony/web-profiler-bundle": "<6.4", - "symfony/webhook": "<7.4", - "symfony/workflow": "<7.4" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, - "require-dev": { - "doctrine/persistence": "^1.3|^2|^3", - "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "seld/jsonlint": "^1.10", - "symfony/asset": "^6.4|^7.0|^8.0", - "symfony/asset-mapper": "^6.4|^7.0|^8.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4.43|^7.4.15|^8.0.15", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/dotenv": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/form": "^7.4|^8.0", - "symfony/html-sanitizer": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/json-streamer": "^7.3|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/mailer": "^6.4|^7.0|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", - "symfony/notifier": "^6.4|^7.0|^8.0", - "symfony/object-mapper": "^7.3|^8.0", - "symfony/polyfill-intl-icu": "~1.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/runtime": "^6.4.13|^7.1.6|^8.0", - "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", - "symfony/security-bundle": "^6.4|^7.0|^8.0", - "symfony/semaphore": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.2.5|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/string": "^6.4|^7.0|^8.0", - "symfony/translation": "^7.3|^8.0", - "symfony/twig-bundle": "^6.4|^7.0|^8.0", - "symfony/type-info": "^7.1.8|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/web-link": "^6.4|^7.0|^8.0", - "symfony/webhook": "^7.4|^8.0", - "symfony/workflow": "^7.4|^8.0", - "symfony/yaml": "^7.3|^8.0", - "twig/twig": "^3.12|^4.0" + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } }, - "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Bundle\\FrameworkBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2548,18 +3054,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.17" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -2579,46 +3093,34 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:38:51+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/http-foundation", - "version": "v7.4.17", + "name": "symfony/filesystem", + "version": "v7.4.18", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f" + "url": "https://github.com/symfony/filesystem.git", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/2ebe78c083501dfb9509b31a7aedcae4d60a391f", - "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.1" - }, - "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", - "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" + "Symfony\\Component\\Filesystem\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2638,10 +3140,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Defines an object-oriented layer for the HTTP specification", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.17" + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" }, "funding": [ { @@ -2661,83 +3163,32 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:55:18+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { - "name": "symfony/http-kernel", + "name": "symfony/finder", "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/http-kernel.git", - "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4" + "url": "https://github.com/symfony/finder.git", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/aa160388d444210e3d01bbb4c5c53af4cd763df4", - "reference": "aa160388d444210e3d01bbb4c5c53af4cd763df4", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", - "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", - "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", - "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "php": ">=8.2" }, "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12|^4.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HttpKernel\\": "" + "Symfony\\Component\\Finder\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2757,10 +3208,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a structured process for converting a Request into a Response", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.17" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -2780,62 +3231,45 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:41:33+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { - "name": "symfony/messenger", - "version": "v7.4.17", + "name": "symfony/flex", + "version": "v2.11.0", "source": { "type": "git", - "url": "https://github.com/symfony/messenger.git", - "reference": "3976018381c403e369d3027592e911b979eaeed9" + "url": "https://github.com/symfony/flex.git", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/3976018381c403e369d3027592e911b979eaeed9", - "reference": "3976018381c403e369d3027592e911b979eaeed9", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/deprecation-contracts": "^2.5|^3" + "composer-plugin-api": "^2.1", + "php": ">=8.1" }, "conflict": { - "symfony/console": "<7.2", - "symfony/event-dispatcher": "<6.4", - "symfony/event-dispatcher-contracts": "<2.5", - "symfony/framework-bundle": "<6.4", - "symfony/http-kernel": "<7.3", - "symfony/lock": "<7.4", - "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" }, "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/console": "^7.2|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^7.3|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "composer/composer": "^2.1", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Messenger\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Flex\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2843,18 +3277,1235 @@ ], "authors": [ { - "name": "Samuel Roze", - "email": "samuel.roze@gmail.com" + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "description": "Helps applications send and receive messages to/from other applications or via message queues", - "homepage": "https://symfony.com", + "time": "2026-05-29T17:25:22+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "c7f3c14c51fccba466761e8c0e7dafe4335acc16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c7f3c14c51fccba466761e8c0e7dafe4335acc16", + "reference": "c7f3c14c51fccba466761e8c0e7dafe4335acc16", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4.43|^7.4.15|^8.0.15", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T19:38:51+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "68d81f78d127984ff2e741f0e0d9cb1078f8f3ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/68d81f78d127984ff2e741f0e0d9cb1078f8f3ea", + "reference": "68d81f78d127984ff2e741f0e0d9cb1078f8f3ea", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T13:49:59+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T21:24:29+00:00" + }, + { + "name": "symfony/lock", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "4073e583b1cb048799706d7ba3245328858cfea6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/4073e583b1cb048799706d7ba3245328858cfea6", + "reference": "4073e583b1cb048799706d7ba3245328858cfea6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/mercure", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "920f08c73793f37e94981e5d9fc1a84351ec8c6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/920f08c73793f37e94981e5d9fc1a84351ec8c6c", + "reference": "920f08c73793f37e94981e5d9fc1a84351ec8c6c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.0|^3.0", + "symfony/http-client": "^6.4|^7.3|^8.0", + "symfony/http-foundation": "^6.4|^7.3|^8.0", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "twig/twig": "^2.0|^3.0|^4.0", + "web-token/jwt-library": "^4.1" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances", + "web-token/jwt-library": "To build Mercure protocol 1.0-compatible JSON Web Tokens with WebTokenFactory" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, + "branch-alias": { + "dev-main": "0.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2026-08-11T07:23:36+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "84143afcd5225bbc3cc5e05b9218a05f2ae4a566" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/84143afcd5225bbc3cc5e05b9218a05f2ae4a566", + "reference": "84143afcd5225bbc3cc5e05b9218a05f2ae4a566", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=8.2", + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/mercure": "^0.8", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^6.4|^7.3|^8.0", + "web-token/jwt-library": "^4.1" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration", + "web-token/jwt-library": "To build Mercure protocol 1.0-compatible JSON Web Tokens from a \"jwt.jwks_uri\" option" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.5.0" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2026-08-11T11:25:49+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "3976018381c403e369d3027592e911b979eaeed9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/3976018381c403e369d3027592e911b979eaeed9", + "reference": "3976018381c403e369d3027592e911b979eaeed9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/messenger/tree/v7.4.17" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -2874,28 +4525,25 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { "php": ">=7.2" }, - "suggest": { - "ext-intl": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -2908,8 +4556,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2925,18 +4576,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "grapheme", - "intl", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -2956,28 +4605,25 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { "php": ">=7.2" }, - "suggest": { - "ext-intl": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -2990,7 +4636,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -3010,18 +4656,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "intl", - "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -3041,31 +4685,30 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { - "ext-iconv": "*", "php": ">=7.2" }, "provide": { - "ext-mbstring": "*" + "ext-uuid": "*" }, "suggest": { - "ext-mbstring": "For best performance" + "ext-uuid": "For best performance" }, "type": "library", "extra": { @@ -3079,7 +4722,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" + "Symfony\\Polyfill\\Uuid\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -3088,25 +4731,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", "polyfill", "portable", - "shim" + "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -3126,41 +4768,37 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.41.0", + "name": "symfony/property-access", + "version": "v7.4.16", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + "url": "https://github.com/symfony/property-access.git", + "reference": "c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "url": "https://api.github.com/repos/symfony/property-access/zipball/c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3", + "reference": "c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3169,24 +4807,29 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + "source": "https://github.com/symfony/property-access/tree/v7.4.16" }, "funding": [ { @@ -3206,41 +4849,49 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-07-30T12:37:26+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "name": "symfony/property-info", + "version": "v7.4.18", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "url": "https://github.com/symfony/property-info.git", + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/property-info/zipball/52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3249,24 +4900,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/property-info/tree/v7.4.18" }, "funding": [ { @@ -3286,45 +4939,47 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { - "name": "symfony/polyfill-uuid", - "version": "v1.37.0", + "name": "symfony/routing", + "version": "v7.4.17", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + "url": "https://github.com/symfony/routing.git", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, - "provide": { - "ext-uuid": "*" + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, - "suggest": { - "ext-uuid": "For best performance" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Uuid\\": "" - } + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3332,24 +4987,24 @@ ], "authors": [ { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for uuid functions", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "uuid" + "router", + "routing", + "uri", + "url" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + "source": "https://github.com/symfony/routing/tree/v7.4.17" }, "funding": [ { @@ -3369,43 +5024,46 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { - "name": "symfony/routing", - "version": "v7.4.17", + "name": "symfony/runtime", + "version": "v7.4.14", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" + "url": "https://github.com/symfony/runtime.git", + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", - "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "url": "https://api.github.com/repos/symfony/runtime/zipball/15497f743dd714f3167cb6a56509b9d42e6417b3", + "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.2" }, "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" + "symfony/dotenv": "<6.4", + "symfony/http-foundation": "<6.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", + "composer/composer": "^2.6", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" }, "exclude-from-classmap": [ "/Tests/" @@ -3417,24 +5075,21 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Enables decoupling PHP applications from global state", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" + "runtime" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.17" + "source": "https://github.com/symfony/runtime/tree/v7.4.14" }, "funding": [ { @@ -3454,46 +5109,68 @@ "type": "tidelift" } ], - "time": "2026-08-17T13:12:36+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { - "name": "symfony/runtime", - "version": "v7.4.14", + "name": "symfony/serializer", + "version": "v7.4.18", "source": { "type": "git", - "url": "https://github.com/symfony/runtime.git", - "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3" + "url": "https://github.com/symfony/serializer.git", + "reference": "1f31d4e902c85b820af5dce27dbe17435524bed0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/15497f743dd714f3167cb6a56509b9d42e6417b3", - "reference": "15497f743dd714f3167cb6a56509b9d42e6417b3", + "url": "https://api.github.com/repos/symfony/serializer/zipball/1f31d4e902c85b820af5dce27dbe17435524bed0", + "reference": "1f31d4e902c85b820af5dce27dbe17435524bed0", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0|^2.0", - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" }, "conflict": { - "symfony/dotenv": "<6.4", - "symfony/http-foundation": "<6.4" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", + "symfony/property-info": "<6.4.43", + "symfony/type-info": "<7.2.5", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "composer/composer": "^2.6", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^7.2|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" - }, - "type": "composer-plugin", - "extra": { - "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", + "symfony/property-info": "^6.4.43|^7.4.15|^8.0.15", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.2.5|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Runtime\\": "", - "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + "Symfony\\Component\\Serializer\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3505,21 +5182,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Enables decoupling PHP applications from global state", + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", - "keywords": [ - "runtime" - ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.14" + "source": "https://github.com/symfony/serializer/tree/v7.4.18" }, "funding": [ { @@ -3539,20 +5213,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:22:21+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -3606,7 +5280,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -3626,7 +5300,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", @@ -3719,6 +5393,89 @@ ], "time": "2026-07-28T07:33:02+00:00" }, + { + "name": "symfony/type-info", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T15:52:48+00:00" + }, { "name": "symfony/uid", "version": "v7.4.17", @@ -3799,16 +5556,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "53712df8727da1744490202eeb9cb50d4b95419d" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53712df8727da1744490202eeb9cb50d4b95419d", - "reference": "53712df8727da1744490202eeb9cb50d4b95419d", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { @@ -3862,7 +5619,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.17" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -3882,20 +5639,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.16", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "ca31404415670aa3834809005b529df1b84f0790" + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/ca31404415670aa3834809005b529df1b84f0790", - "reference": "ca31404415670aa3834809005b529df1b84f0790", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603", "shasum": "" }, "require": { @@ -3943,7 +5700,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.16" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.18" }, "funding": [ { @@ -3963,7 +5720,94 @@ "type": "tidelift" } ], - "time": "2026-07-30T12:37:26+00:00" + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/web-link", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-link.git", + "reference": "0711009963009e7d6d59149327f3ad633ee3fe25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-link/zipball/0711009963009e7d6d59149327f3ad633ee3fe25", + "reference": "0711009963009e7d6d59149327f3ad633ee3fe25", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/link": "^1.1|^2.0" + }, + "conflict": { + "symfony/http-kernel": "<6.4" + }, + "provide": { + "psr/link-implementation": "1.0|2.0" + }, + "require-dev": { + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\WebLink\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages links between resources", + "homepage": "https://symfony.com", + "keywords": [ + "dns-prefetch", + "http", + "http2", + "link", + "performance", + "prefetch", + "preload", + "prerender", + "psr13", + "push" + ], + "support": { + "source": "https://github.com/symfony/web-link/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/yaml", @@ -4040,6 +5884,72 @@ } ], "time": "2026-08-21T12:09:28+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -6481,6 +8391,7 @@ "minimum-stability": "stable", "stability-flags": { "gplanchat/durable": 20, + "gplanchat/durable-bridge-dbal": 20, "gplanchat/durable-bridge-temporal": 20, "gplanchat/durable-bundle": 20, "gplanchat/durable-demo-contracts": 20 diff --git a/symfony/config/bundles.php b/symfony/config/bundles.php index 1418d8737..0063900fe 100644 --- a/symfony/config/bundles.php +++ b/symfony/config/bundles.php @@ -2,9 +2,11 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Gplanchat\Bridge\Temporal\TemporalBridgeBundle::class => ['all' => true], Gplanchat\Durable\Bundle\DurableBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/symfony/config/packages/doctrine.yaml b/symfony/config/packages/doctrine.yaml new file mode 100644 index 000000000..316e1d13a --- /dev/null +++ b/symfony/config/packages/doctrine.yaml @@ -0,0 +1,6 @@ +# Le backend DBAL du spike (DUR030) n'a besoin que d'une connexion : les tables `durable_*` sont +# créées à la première écriture, et il n'y a aucune entité ORM dans cette app. +# SQLite volontairement : le chat doit survivre à la requête HTTP, pas exiger une infra. +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' diff --git a/symfony/config/packages/durable.yaml b/symfony/config/packages/durable.yaml index 92e0519cc..ddb443b3c 100644 --- a/symfony/config/packages/durable.yaml +++ b/symfony/config/packages/durable.yaml @@ -22,6 +22,12 @@ durable: - App\Durable\Activity\TickActivityInterface - App\Durable\Activity\ChargeActivityInterface +# Temporal natif : le cluster **est** le journal. C'est ce qui rend le chat possible entre deux +# requêtes — un event store in-memory non enveloppé perdrait le WorkflowSignalReceived écrit par +# DeliverWorkflowSignalHandler. Ici l'in-memory n'est que le cache local d'un +# TemporalReadThroughEventStore, et les signaux passent par le cluster (WorkflowClient::signal()), +# pas par un append local. +# Le backend DBAL (DUR030) reste la variante sans cluster : event_store.type dbal + temporal.dsn null. when@dev: durable: event_store: diff --git a/symfony/config/packages/lock.yaml b/symfony/config/packages/lock.yaml new file mode 100644 index 000000000..796c62c7b --- /dev/null +++ b/symfony/config/packages/lock.yaml @@ -0,0 +1,3 @@ +# `durable.dbal.lock_factory` sérialise les reprises d'une même exécution (DUR030). +framework: + lock: 'flock' diff --git a/symfony/config/packages/mercure.yaml b/symfony/config/packages/mercure.yaml new file mode 100644 index 000000000..fb0241719 --- /dev/null +++ b/symfony/config/packages/mercure.yaml @@ -0,0 +1,12 @@ +mercure: + hubs: + default: + # Ce que l'application et les workers appellent pour publier. Ils tournent sur l'hôte, + # donc ils passent par le port publié du conteneur, comme le navigateur. + url: '%env(MERCURE_URL)%' + # Ce que la page reçoit pour s'abonner. Distinct de `url` par principe : le jour où + # l'application entre dans le réseau compose, seul celui-ci reste joignable du dehors. + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' diff --git a/symfony/config/reference.php b/symfony/config/reference.php index f63dbe4da..3ce45b9ef 100644 --- a/symfony/config/reference.php +++ b/symfony/config/reference.php @@ -352,7 +352,7 @@ * enabled?: bool|Param, // Default: false * }, * serializer?: bool|array{ // Serializer configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true * name_converter?: scalar|Param|null, * circular_reference_handler?: scalar|Param|null, @@ -369,7 +369,7 @@ * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * magic_call?: bool|Param, // Default: false * magic_get?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true @@ -377,11 +377,11 @@ * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * }, * cache?: array{ // Cache configuration @@ -415,10 +415,10 @@ * log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null * }>, * web_link?: bool|array{ // Web links configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * }, * lock?: Param|bool|string|array{ // Lock configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * resources?: Param|string|array>, * }, * semaphore?: Param|bool|string|array{ // Semaphore configuration @@ -472,7 +472,7 @@ * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, @@ -690,6 +690,242 @@ * enabled?: bool|Param, // Default: false * }, * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|Param|null, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, + * slaves?: array + * }>, + * replicas?: array + * }>, + * ... + * }>, + * ... + * }, + * orm?: array{ + * default_entity_manager?: scalar|Param|null, + * auto_generate_proxy_classes?: scalar|Param|null, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false + * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true + * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false + * proxy_dir?: scalar|Param|null, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" + * proxy_namespace?: scalar|Param|null, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|Param|null, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * ... + * }, + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: Param|string|array{ + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, + * }, + * region_lock_lifetime?: scalar|Param|null, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|Param|null, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|Param|null, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * ... + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * ... + * }, + * } * @psalm-type DurableConfig = array{ * dbal?: array{ // Backend DBAL : exécution durable sur une seule base SQL, sans cluster d'orchestration (DUR030). * connection?: scalar|Param|null, // Service ID de la Doctrine\DBAL\Connection à utiliser // Default: "doctrine.dbal.default_connection" @@ -767,38 +1003,72 @@ * intercept_redirects?: bool|Param, // Default: false * excluded_ajax_paths?: scalar|Param|null, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" * } + * @psalm-type MercureConfig = array{ + * hubs?: array, + * subscribe?: list, + * secret?: scalar|Param|null, // The JWT Secret to use. + * passphrase?: scalar|Param|null, // The JWT secret passphrase. // Default: "" + * algorithm?: scalar|Param|null, // The algorithm to use to sign the JWT. With "secret", one of LcobucciFactory::SIGN_ALGORITHMS ("hmac.sha256", the default). With "jwks_uri", a JWA name from WebTokenFactory::SIGN_ALGORITHMS ("HS256", the default). + * jwks_uri?: scalar|Param|null, // URL of a JSON Web Key Set (JWKS) to fetch the signing key from, instead of "secret". Requires "protocol_version: 1.0" and "web-token/jwt-library". + * key_id?: scalar|Param|null, // The "kid" of the key to select from "jwks_uri", required when the key set holds more than one matching key. + * claims?: array, + * }, + * jwt_provider?: scalar|Param|null, // Deprecated: The child node "jwt_provider" at path "mercure.hubs..jwt_provider" is deprecated, use "jwt.provider" instead. // The ID of a service to call to generate the JSON Web Token. + * bus?: scalar|Param|null, // Name of the Messenger bus where the handler for this hub must be registered. Default to the default bus if Messenger is enabled. + * protocol_version?: "0.x"|"1.0"|Param, // The Mercure protocol version spoken by this hub: "0.x" (default) or "1.0". Affects the default cookie name, the JWT claim shape built by "jwt.secret", and how the mercure() Twig function interprets matcher-typed topics. // Default: "0.x" + * cookie_name?: scalar|Param|null, // Name of the subscriber authorization cookie. Defaults to a value computed from "protocol_version" ("mercureAuthorization" for "0.x", "__Secure-mercure_access_token" for "1.0") when not set. // Default: null + * }>, + * default_hub?: scalar|Param|null, + * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null + * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, * durable?: DurableConfig, * twig?: TwigConfig, + * mercure?: MercureConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, * durable?: DurableConfig, * twig?: TwigConfig, * web_profiler?: WebProfilerConfig, + * mercure?: MercureConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, * durable?: DurableConfig, * twig?: TwigConfig, + * mercure?: MercureConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, * durable?: DurableConfig, * twig?: TwigConfig, * web_profiler?: WebProfilerConfig, + * mercure?: MercureConfig, * }, * ...tools->has($name)) { + // ponytail: l'échec remonte et tue l'appel agent. Le renvoyer au modèle comme + // résultat d'outil est l'autre politique possible — c'est DUR011 qui doit trancher. + throw new \InvalidArgumentException(\sprintf('Outil "%s" inconnu.', $name)); + } + + return (string) $this->tools->get($name)($arguments); + } +} diff --git a/symfony/src/Ai/Activity/AgentToolActivityInterface.php b/symfony/src/Ai/Activity/AgentToolActivityInterface.php new file mode 100644 index 000000000..b574c1989 --- /dev/null +++ b/symfony/src/Ai/Activity/AgentToolActivityInterface.php @@ -0,0 +1,22 @@ + $arguments + */ + #[AsActivityMethod('ai_tool_call')] + public function callTool(string $callId, string $name, array $arguments): string; +} diff --git a/symfony/src/Ai/Activity/ModelInvocationActivityHandler.php b/symfony/src/Ai/Activity/ModelInvocationActivityHandler.php new file mode 100644 index 000000000..a47c949f0 --- /dev/null +++ b/symfony/src/Ai/Activity/ModelInvocationActivityHandler.php @@ -0,0 +1,54 @@ +client->request($this->catalog->getModel($model), $payload, $options); + + // L'erreur HTTP appartient à l'activité, pas au code workflow : un 429 ou un 503 doit + // rencontrer la politique de retentative de Durable (DUR011), pas être journalisé comme + // un succès puis faire s'étrangler le convertisseur au rejeu. + $response = $raw->getObject(); + $data = $raw->getData(); + + // Un dépassement de fenêtre n'est pas une panne : c'est une réponse. La rejouer donnerait + // le même verdict, donc elle est journalisée comme une donnée et c'est le code workflow + // qui compacte puis redemande. + if (ContextOverflow::detected($data)) { + return $data; + } + + if (method_exists($response, 'getStatusCode') && ($code = $response->getStatusCode()) >= 400) { + throw new \RuntimeException(\sprintf('Le fournisseur a répondu %d : %s', $code, $response->getContent(false))); + } + + return $data; + } + + public function compactConversation(string $model, array $payload, array $options): array + { + return $this->invokeModel($model, $payload, $options); + } +} diff --git a/symfony/src/Ai/Activity/ModelInvocationActivityInterface.php b/symfony/src/Ai/Activity/ModelInvocationActivityInterface.php new file mode 100644 index 000000000..7075d8cf7 --- /dev/null +++ b/symfony/src/Ai/Activity/ModelInvocationActivityInterface.php @@ -0,0 +1,40 @@ + $payload + * @param array $options + * + * @return array + */ + #[AsActivityMethod('ai_model_invoke')] + public function invokeModel(string $model, array $payload, array $options): array; + + /** + * Le même appel, sous un autre nom d'activité. + * + * Le fournisseur ne fait pas la différence, la **projection** si : elle recompose le fil à + * partir du dernier `ai_model_invoke`, et la charge d'une compaction est la conversation qu'on + * s'apprête à remplacer. Sous le même nom, une reprise restée silencieuse réafficherait + * l'ancienne conversation comme si elle était le tour en cours. + * + * @param array $payload + * @param array $options + * + * @return array + */ + #[AsActivityMethod('ai_model_compact')] + public function compactConversation(string $model, array $payload, array $options): array; +} diff --git a/symfony/src/Ai/Chat/ChatTranscript.php b/symfony/src/Ai/Chat/ChatTranscript.php new file mode 100644 index 000000000..c8221353b --- /dev/null +++ b/symfony/src/Ai/Chat/ChatTranscript.php @@ -0,0 +1,300 @@ + $payload + * + * @return array + */ + private static function descendTo(array $payload, string $key): array + { + while (!\array_key_exists($key, $payload) && \is_array($payload['payload'] ?? null)) { + $payload = $payload['payload']; + } + + return $payload; + } + + /** + * Quand l'échéance tranchera à la place de l'humain. + * + * `TimerScheduled::scheduledAt()` ne dit pas la même chose selon le backend : le cœur y met + * l'instant de tir, le pont Temporal l'instant de départ — son convertisseur construit + * `new TimerScheduled($id, $timerId, $ts)` avec l'horodatage de l'événement et laisse tomber + * `startToFireTimeout`. Tant que l'attente dure, le tir est forcément à venir : c'est ce qui + * permet de trancher entre les deux lectures sans deviner le backend. + * + * @param array $deadlines minuteurs encore en vol + */ + private static function expiryOf(array $deadlines, ?Duration $timeout): ?float + { + if ([] === $deadlines) { + return null; + } + + $scheduled = (float) end($deadlines); + + return $scheduled >= microtime(true) || null === $timeout + ? $scheduled + : $scheduled + $timeout->toSeconds(); + } + + public function forExecution(string $executionId): Transcript + { + $messages = []; + $steps = []; + $results = []; + $lastModelCallId = null; + $compactionCallId = null; + $finished = false; + $settled = new SettledCalls(); + $signalledMode = null; + $messagesSignalled = 0; + $deadlines = []; + // La charge de démarrage n'est pas au même endroit selon le backend : sur Temporal natif + // elle ouvre le journal (ExecutionStarted), sur DBAL un run dispatché n'écrit que ses + // événements d'exécution et la charge reste dans le store de métadonnées. On lit les deux. + // + // L'événement passe devant, et ce n'est pas un détail de style : après un continue-as-new, + // le store de métadonnées porte encore la charge du **premier** run — celle d'avant le + // relais, dont le fil est vide. Seul l'événement du run courant dit ce que ce run a repris. + $startedFromStore = $this->metadataStore->get($executionId)['payload'] ?? []; + $started = []; + + foreach ($this->eventStore->readStream($executionId) as $event) { + if ($event instanceof ActivityScheduled) { + $payload = $event->payload(); + + if ('ai_model_invoke' === $event->activityName()) { + $messages = self::descendTo($payload, 'messages')['messages'] ?? $messages; + $lastModelCallId = $event->activityId(); + + continue; + } + + // La compaction est un appel modèle comme un autre, sous un nom à part : sa charge + // est la conversation qu'on remplace, pas celle du tour en cours. + if ('ai_model_compact' === $event->activityName()) { + $compactionCallId = $event->activityId(); + + continue; + } + + if ('ai_tool_call' === $event->activityName()) { + $call = self::descendTo($payload, 'arguments'); + $settled->settle((string) ($call['callId'] ?? '')); + $steps[$event->activityId()] = new ToolStep( + (string) ($call['callId'] ?? ''), + (string) ($call['name'] ?? '?'), + (array) ($call['arguments'] ?? []), + null, + ); + } + + continue; + } + + if ($event instanceof TimerScheduled) { + // Pas de filtre sur le résumé : il ne survit pas à l'aller-retour Temporal, où + // l'événement revient sans lui. Un agent ne planifie de minuteur qu'ici, donc le + // dernier minuteur encore en vol est l'échéance de l'attente en cours. + $deadlines[$event->timerId()] = $event->scheduledAt(); + + continue; + } + + if ($event instanceof TimerCompleted || $event instanceof TimerCancelled) { + unset($deadlines[$event->timerId()]); + + continue; + } + + if ($event instanceof ActivityCompleted) { + $results[$event->activityId()] = $event->result(); + + continue; + } + + if ($event instanceof WorkflowSignalReceived) { + $signal = $event->signalPayload(); + if ('user_message' === $event->signalName()) { + ++$messagesSignalled; + } elseif (\in_array($event->signalName(), ['tool_decision', 'question_answered', 'alerte'], true)) { + $settled->settle((string) ($signal['callId'] ?? '')); + } elseif ('set_mode' === $event->signalName()) { + $signalledMode = AgentMode::tryFrom((string) ($signal['mode'] ?? '')) ?? $signalledMode; + } + + continue; + } + + if ($event instanceof ExecutionStarted) { + $started = $event->payload(); + + continue; + } + + if ($event instanceof ExecutionCompleted) { + $finished = true; + } + } + + $started = [] !== $started ? $started : $startedFromStore; + + // Un run repris — reprise après clôture ou continue-as-new — porte son fil d'origine dans + // sa charge de démarrage. Tant qu'aucun appel modèle ne l'a réémis, c'est la seule trace + // qu'en ait le journal ; sans ça la conversation paraît s'être vidée. + // + // Sauf s'il l'a compacté : c'est alors le résumé qui fait foi, dès avant le premier tour. + // L'afficher plus tôt n'aurait pas seulement l'air faux — le fil complet compterait des + // messages que le modèle ne verra jamais, et fausserait le statut de l'agent. + $digest = null !== $compactionCallId + ? ChatCompletion::fromWire($results[$compactionCallId] ?? null)?->text + : null; + $carried = null !== $digest && '' !== trim($digest) + ? [TranscriptMessage::compaction(trim($digest))->toWire()] + : $started['history'] ?? []; + if ([] === $messages) { + $messages = $carried; + } + + // 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. + $messagesSignalled += \count(array_filter( + $carried, + static fn (array $message): bool => 'user' === ($message['role'] ?? null), + )); + + // Le dernier `set_mode` l'emporte sur le mode de démarrage : il lui est postérieur. + $mode = $signalledMode ?? AgentMode::tryFrom((string) ($started['mode'] ?? '')) ?? AgentMode::Standard; + $humanTimeout = Duration::fromWireValue($started['humanTimeoutSeconds'] ?? null); + + foreach ($steps as $activityId => $step) { + $result = $results[$activityId] ?? null; + $steps[$activityId] = $step->withResult(\is_string($result) ? $result : null); + } + + // Deux attentes humaines, lues au même endroit : le modèle a demandé un outil, et ni + // l'activité ni la réponse ne sont au journal. La garde retient l'un, le guichet l'autre. + $expiresAt = self::expiryOf($deadlines, $humanTimeout); + $answer = ChatCompletion::fromWire($results[$lastModelCallId] ?? null); + $pending = []; + $questions = []; + $watches = []; + foreach ($answer?->toolCalls ?? [] as $ref) { + if ($settled->has($ref->callId)) { + continue; + } + + if (AskUserQuestion::TOOL === $ref->tool) { + $questions[] = PendingQuestion::fromArguments($ref->callId, $ref->arguments, $expiresAt); + + continue; + } + + if (WatchTool::TOOL === $ref->tool) { + $watches[] = Watch::fromArguments($ref->callId, $ref->arguments, $expiresAt); + + continue; + } + + $pending[] = new PendingApproval( + $ref->callId, + $ref->tool, + $ref->arguments, + 'En attente de ta décision.', + $expiresAt, + ); + } + + // Un message signalé que le dernier appel modèle ne contient pas encore : le tour a commencé + // mais le journal n'en porte pas encore la trace. Sans ça l'agent paraît inactif entre la + // soumission et la planification de l'activité. + $messagesSeenByModel = \count(array_filter( + $messages, + static fn (array $message): bool => 'user' === ($message['role'] ?? null), + )); + + $thread = array_values(array_filter( + array_map(TranscriptMessage::fromWire(...), $messages), + static fn (TranscriptMessage $message): bool => !$message->isSystem(), + )); + + // Le raisonnement du dernier tour n'est nulle part ailleurs : les tours précédents ont le + // leur dans la charge du tour d'après (le normaliseur l'y remet), mais le dernier n'a pas + // de tour d'après. Il se lit dans le résultat, mêlé à la réponse — et c'est {@see ChatCompletion} + // qui fait le découpage, le même que pour le fil. + if (null !== $answer?->text && '' !== $answer->text) { + $thread[] = TranscriptMessage::assistant($answer->text, $answer->reasoning); + } + + return new Transcript( + $thread, + array_values($steps), + $pending, + $questions, + $watches, + $mode, + $humanTimeout, + // Quatre façons d'avoir un tour en cours : une compaction en vol, un message reçu que + // le modèle n'a pas encore vu, un appel modèle planifié sans résultat, ou une réponse + // qui demande encore des outils. Aucun appel modèle du tout n'est *pas* un tour en + // cours : c'est l'état de départ, où le workflow est suspendu sur son premier signal. + // + // Et trois façons de ne PAS travailler tout en n'ayant pas fini : la balle est dans le + // camp de l'humain — une validation, une question, une veille armée. Ce n'est pas de + // l'attente machine, et l'afficher comme telle ferait mentir le statut. + !$finished && [] === $pending && [] === $questions && [] === $watches && ( + (null !== $compactionCallId && !\array_key_exists($compactionCallId, $results)) + || $messagesSignalled > $messagesSeenByModel + || (null !== $lastModelCallId && !\array_key_exists($lastModelCallId, $results)) + || (null !== $lastModelCallId && null === $answer?->text) + ), + $finished, + ); + } +} diff --git a/symfony/src/Ai/Chat/SettledCalls.php b/symfony/src/Ai/Chat/SettledCalls.php new file mode 100644 index 000000000..f8fa7ef77 --- /dev/null +++ b/symfony/src/Ai/Chat/SettledCalls.php @@ -0,0 +1,38 @@ +` parcourus par une seule condition à quatre `isset()`, où ajouter + * une cinquième façon de régler un appel voulait dire ne pas oublier de l'ajouter aux deux endroits. + * + * Un ensemble, une question : {@see has()}. + */ +final class SettledCalls +{ + /** @var array */ + private array $ids = []; + + /** + * L'identifiant vide entre comme un autre, et c'est délibéré : c'est ce que faisaient les + * quatre tableaux. Un signal malformé règle alors l'appel sans identifiant — deux cas + * dégénérés qui s'annulent. Le refuser ici serait une correction, pas une reformulation, et + * elle changerait le statut affiché. + */ + public function settle(string $callId): void + { + $this->ids[$callId] = true; + } + + public function has(string $callId): bool + { + return isset($this->ids[$callId]); + } +} diff --git a/symfony/src/Ai/Chat/ToolCallRef.php b/symfony/src/Ai/Chat/ToolCallRef.php new file mode 100644 index 000000000..9287a6b0c --- /dev/null +++ b/symfony/src/Ai/Chat/ToolCallRef.php @@ -0,0 +1,42 @@ + $arguments + */ + public function __construct( + public string $callId, + public string $tool, + public array $arguments, + ) { + } + + /** + * @param array $wire + */ + public static function fromWire(array $wire): self + { + return new self( + (string) ($wire['id'] ?? ''), + (string) ($wire['function']['name'] ?? '?'), + json_decode((string) ($wire['function']['arguments'] ?? '{}'), true) ?: [], + ); + } + + /** + * @return array{callId: string, tool: string, arguments: array} + */ + public function jsonSerialize(): array + { + return ['callId' => $this->callId, 'tool' => $this->tool, 'arguments' => $this->arguments]; + } +} diff --git a/symfony/src/Ai/Chat/ToolStep.php b/symfony/src/Ai/Chat/ToolStep.php new file mode 100644 index 000000000..0f0eeea87 --- /dev/null +++ b/symfony/src/Ai/Chat/ToolStep.php @@ -0,0 +1,39 @@ + $arguments + */ + public function __construct( + public string $callId, + public string $tool, + public array $arguments, + public ?string $result, + ) { + } + + public function withResult(?string $result): self + { + return new self($this->callId, $this->tool, $this->arguments, $result); + } + + /** + * `callId` sort jusqu'au fil : c'est lui qui permet d'apparier un appel affiché à l'activité + * qui l'exécute, donc de dire « celle-ci tourne encore ». Apparier sur le nom et les arguments + * marcherait jusqu'au premier agent qui appelle deux fois le même outil pareil. + * + * @return array{callId: string, tool: string, arguments: array, result: string|null} + */ + public function jsonSerialize(): array + { + return ['callId' => $this->callId, 'tool' => $this->tool, 'arguments' => $this->arguments, 'result' => $this->result]; + } +} diff --git a/symfony/src/Ai/Chat/Transcript.php b/symfony/src/Ai/Chat/Transcript.php new file mode 100644 index 000000000..a2728e857 --- /dev/null +++ b/symfony/src/Ai/Chat/Transcript.php @@ -0,0 +1,72 @@ + $messages + * @param list $steps + * @param list $pending validations retenues par la garde + * @param list $questions questions posées par l'agent + * @param list $watches veilles en cours + */ + public function __construct( + public array $messages, + public array $steps, + public array $pending, + public array $questions, + public array $watches, + public AgentMode $mode, + public ?Duration $humanTimeout, + public bool $working, + public bool $finished, + ) { + } + + /** + * Ce qu'une exécution neuve doit reprendre de celle-ci : le fil parlé, sans la mécanique + * d'outils du run qui s'achève. + * + * @return list + */ + public function seed(): array + { + return TranscriptMessage::listToWire(array_filter( + $this->messages, + static fn (TranscriptMessage $message): bool => $message->carriesText(), + )); + } + + /** + * @return array{messages: list, steps: list, pending: list, questions: list, watches: list, mode: string, humanTimeoutSeconds: float|null, working: bool, finished: bool} + */ + public function jsonSerialize(): array + { + return [ + 'messages' => $this->messages, + 'steps' => $this->steps, + 'pending' => $this->pending, + 'questions' => $this->questions, + 'watches' => $this->watches, + 'mode' => $this->mode->value, + 'humanTimeoutSeconds' => $this->humanTimeout?->toSeconds(), + 'working' => $this->working, + 'finished' => $this->finished, + ]; + } +} diff --git a/symfony/src/Ai/Chat/TranscriptMessage.php b/symfony/src/Ai/Chat/TranscriptMessage.php new file mode 100644 index 000000000..95b118845 --- /dev/null +++ b/symfony/src/Ai/Chat/TranscriptMessage.php @@ -0,0 +1,196 @@ + $toolCalls + */ + private function __construct( + public string $role, + public ?string $content, + public array $toolCalls, + public ?string $reasoning = null, + ) { + } + + /** + * @param array $wire + */ + public static function fromWire(array $wire): self + { + [$content, $reasoning] = self::splitContent($wire['content'] ?? null, $wire['reasoning_content'] ?? null); + + return new self( + (string) ($wire['role'] ?? 'assistant'), + $content, + array_map(ToolCallRef::fromWire(...), $wire['tool_calls'] ?? []), + $reasoning, + ); + } + + /** + * Sépare le dit du pensé, dans la forme que le pont Mistral donne à un tour raisonné : une + * liste de morceaux `thinking` et `text` à la place de la chaîne habituelle. + * + * Le contrat générique, lui, met le raisonnement dans un `reasoning_content` à côté — que + * Mistral refuse par un 422. Les deux formes se lisent donc ici, parce que le fil est une + * projection du journal et qu'un journal porte ce que le fournisseur du jour y a écrit : + * changer de pont ne doit pas rendre illisibles les conversations d'avant. + * + * @param mixed $content la valeur brute de `message.content` + * + * @return array{0: string|null, 1: string|null} le texte, puis le raisonnement + */ + public static function splitContent(mixed $content, ?string $sidecar = null): array + { + $reasoning = trim((string) ($sidecar ?? '')); + + if (!\is_array($content)) { + $text = null !== $content ? (string) $content : null; + + return [$text, '' === $reasoning ? null : $reasoning]; + } + + $text = ''; + foreach ($content as $chunk) { + if (!\is_array($chunk)) { + continue; + } + + if ('text' === ($chunk['type'] ?? null) && \is_string($chunk['text'] ?? null)) { + $text .= $chunk['text']; + } elseif ('thinking' === ($chunk['type'] ?? null)) { + foreach ($chunk['thinking'] ?? [] as $part) { + if (\is_array($part) && \is_string($part['text'] ?? null)) { + $reasoning .= $part['text']; + } + } + } + } + + return ['' === $text ? null : $text, '' === trim($reasoning) ? null : trim($reasoning)]; + } + + public static function assistant(string $content, ?string $reasoning = null): self + { + return new self('assistant', $content, [], $reasoning); + } + + public static function user(string $content): self + { + return new self('user', $content, []); + } + + /** + * Le résumé qui remplace une conversation reprise. + * + * Une fabrique et pas deux `sprintf` : le workflow la construit pour le modèle, la projection + * la reconstruit pour l'affichage, et les deux doivent tomber sur le même message — sinon le + * fil visible change de texte au premier tour. + */ + public static function compaction(string $digest): self + { + return new self('assistant', self::COMPACTION_PREFIX . $digest, []); + } + + /** + * Le texte sans son étiquette. + * + * Le préfixe est là pour l'humain. Le renvoyer au modèle à la compaction suivante lui ferait + * résumer un résumé étiqueté — et une reprise se reprend, elle : au deuxième retour, + * l'étiquette se retrouverait imbriquée dans son propre texte. + */ + public function stripped(): self + { + $content = (string) $this->content; + + return str_starts_with($content, self::COMPACTION_PREFIX) + ? new self($this->role, substr($content, \strlen(self::COMPACTION_PREFIX)), $this->toolCalls, $this->reasoning) + : $this; + } + + public function isSystem(): bool + { + return 'system' === $this->role; + } + + public function isUser(): bool + { + return 'user' === $this->role; + } + + /** + * Ce qui a du sens à reprendre dans une nouvelle exécution : les tours parlés. + * + * Un `role: tool` et un message d'assistant qui ne porte que des `tool_calls` racontent la + * mécanique d'un run qui s'achève ; les rejouer dans le suivant remplirait le fil de bulles + * vides et le sac de références d'appels qui n'existent plus. + */ + public function carriesText(): bool + { + return null !== $this->content && '' !== $this->content + && ('user' === $this->role || 'assistant' === $this->role); + } + + /** + * La forme du fil, celle que porte déjà la charge d'un appel modèle — pas un format de plus. + * + * **Le raisonnement n'y va pas.** Cette méthode alimente deux choses qui partent au modèle : la + * charge de compaction, et le fil que le relais transmet au run suivant. Y ajouter une clé + * changerait une charge sortante — et une charge sortante qui change, c'est un rejeu qui + * diverge. Le raisonnement est de l'affichage ; il sort par {@see jsonSerialize()}. + * + * Conséquence assumée : après un relais, le fil repris n'a plus ses blocs de raisonnement. Ce + * qui se reprend, ce sont les tours parlés. + * + * @return array{role: string, content: string} + */ + public function toWire(): array + { + return ['role' => $this->role, 'content' => (string) $this->content]; + } + + /** + * @param list> $wire + * + * @return list + */ + public static function listFromWire(array $wire): array + { + return array_map(self::fromWire(...), array_values($wire)); + } + + /** + * @param list $messages + * + * @return list + */ + public static function listToWire(array $messages): array + { + return array_map(static fn (self $message): array => $message->toWire(), array_values($messages)); + } + + /** + * @return array{role: string, content: string|null, toolCalls: list, reasoning: string|null} + */ + public function jsonSerialize(): array + { + return [ + 'role' => $this->role, + 'content' => $this->content, + 'toolCalls' => $this->toolCalls, + 'reasoning' => $this->reasoning, + ]; + } +} diff --git a/symfony/src/Ai/Context/ContextBudget.php b/symfony/src/Ai/Context/ContextBudget.php new file mode 100644 index 000000000..04bd37b5e --- /dev/null +++ b/symfony/src/Ai/Context/ContextBudget.php @@ -0,0 +1,103 @@ +maxTokens, 2)), $this->reserve); + } + + /** + * @param list> $messages + */ + public function estimate(array $messages): int + { + return intdiv(mb_strlen(json_encode($messages, \JSON_UNESCAPED_UNICODE) ?: ''), self::CHARS_PER_TOKEN); + } + + public function ceiling(): int + { + return (int) ($this->maxTokens * (1 - $this->reserve)); + } + + /** + * Ramène la conversation sous le plafond en abandonnant les tours les plus anciens. + * + * Le découpage en tours vit dans {@see Conversation} : ici on ne fait qu'en retirer par le + * début tant que ça dépasse. C'est {@see Turn} qui garantit qu'un résultat d'outil ne se + * retrouve jamais sans l'appel qui l'a produit. + * + * ponytail: plafond assumé — un tour à lui seul plus gros que la fenêtre ne peut pas être + * compacté, puisque abandonner le message auquel il faut répondre n'aurait pas de sens. Il + * part tel quel, le fournisseur le refuse, et c'est le chemin réactif qui reprend la main. + * Le résumé par le modèle serait la sortie — mais il n'est pas déterministe, donc il ne peut + * pas vivre ici : sa place est dans un `continueAsNew`, où il devient une charge de départ. + * + * @param list> $messages + * + * @return list> + */ + public function fit(array $messages): array + { + if ($this->estimate($messages) <= $this->ceiling()) { + return $messages; + } + + $conversation = Conversation::fromWire($messages); + $avant = $conversation->messageCount(); + + while (!$conversation->hasSingleTurn() && $this->estimate($conversation->toWire()) > $this->ceiling()) { + $conversation = $conversation->withoutOldestTurn(); + } + + $retires = $avant - $conversation->messageCount(); + if (0 === $retires) { + return $messages; + } + + return $conversation->withNotice(\sprintf( + '[%d messages plus anciens ont été retirés du contexte pour tenir dans la fenêtre du ' + .'modèle. Si une information manque, demande-la plutôt que de l\'inventer.]', + $retires, + ))->toWire(); + } +} diff --git a/symfony/src/Ai/Context/ContextOverflow.php b/symfony/src/Ai/Context/ContextOverflow.php new file mode 100644 index 000000000..2fc9aec9b --- /dev/null +++ b/symfony/src/Ai/Context/ContextOverflow.php @@ -0,0 +1,42 @@ + $data corps de la réponse du fournisseur + */ + public static function detected(array $data): bool + { + $code = $data['error']['code'] ?? $data['code'] ?? null; + if ('context_length_exceeded' === $code) { + return true; + } + + $message = (string) ($data['error']['message'] ?? $data['message'] ?? ''); + + return '' !== $message + && (str_contains($message, 'maximum context length') || str_contains($message, 'context_length_exceeded')); + } +} diff --git a/symfony/src/Ai/Context/Conversation.php b/symfony/src/Ai/Context/Conversation.php new file mode 100644 index 000000000..560e4a45e --- /dev/null +++ b/symfony/src/Ai/Context/Conversation.php @@ -0,0 +1,106 @@ +> $system + * @param list $turns + */ + private function __construct( + public array $system, + public array $turns, + ) { + } + + /** + * @param list> $messages + */ + public static function fromWire(array $messages): self + { + $system = []; + $turns = []; + $current = []; + + foreach ($messages as $message) { + // Seuls les messages système *de tête* sont le préambule : un marqueur de compaction + // posé plus loin appartient au tour qu'il précède. + if ('system' === ($message['role'] ?? null) && [] === $turns && [] === $current) { + $system[] = $message; + + continue; + } + + if ('user' === ($message['role'] ?? null) && [] !== $current) { + $turns[] = Turn::of($current); + $current = []; + } + + $current[] = $message; + } + + if ([] !== $current) { + $turns[] = Turn::of($current); + } + + return new self($system, $turns); + } + + /** + * @return list> + */ + public function toWire(): array + { + $messages = $this->system; + foreach ($this->turns as $turn) { + foreach ($turn->messages as $message) { + $messages[] = $message; + } + } + + return $messages; + } + + /** + * Le dernier tour ne part jamais : sans lui il ne resterait rien à quoi répondre. + */ + public function withoutOldestTurn(): self + { + if (\count($this->turns) <= 1) { + return $this; + } + + $turns = $this->turns; + array_shift($turns); + + return new self($this->system, array_values($turns)); + } + + public function hasSingleTurn(): bool + { + return \count($this->turns) <= 1; + } + + public function messageCount(): int + { + return \count($this->system) + array_sum(array_map(static fn (Turn $t): int => $t->count(), $this->turns)); + } + + public function withNotice(string $notice): self + { + return new self([...$this->system, ['role' => 'system', 'content' => $notice]], $this->turns); + } +} diff --git a/symfony/src/Ai/Context/Turn.php b/symfony/src/Ai/Context/Turn.php new file mode 100644 index 000000000..8cadb7bda --- /dev/null +++ b/symfony/src/Ai/Context/Turn.php @@ -0,0 +1,45 @@ +> $messages + */ + private function __construct( + public array $messages, + ) { + } + + /** + * @param non-empty-list> $messages + */ + public static function of(array $messages): self + { + if ([] === $messages) { + throw new \InvalidArgumentException('Un tour porte au moins un message.'); + } + + return new self($messages); + } + + public function count(): int + { + return \count($this->messages); + } +} diff --git a/symfony/src/Ai/Durable/DurableAgentFactory.php b/symfony/src/Ai/Durable/DurableAgentFactory.php new file mode 100644 index 000000000..e085ecf6f --- /dev/null +++ b/symfony/src/Ai/Durable/DurableAgentFactory.php @@ -0,0 +1,91 @@ +with(AskUserQuestion::definition(), WatchTool::definition(), DelegateTool::definition()); + + // Le pont Mistral fournit tout ce qui est **pur** — la normalisation de la conversation, + // le catalogue, la conversion du JSON en résultat — et c'est ce qui tourne en code + // workflow, donc rejoué. Seul son client HTTP est remplacé : lui seul sort du processus, + // et c'est précisément ce qui doit devenir une activité. + $platform = new Platform([ + new Provider( + 'durable-mistral', + [new DurableModelClient($environment, $budget ?? new ContextBudget())], + [new ResultConverter()], + new ModelCatalog(), + Contract::create([new AssistantMessageNormalizer(), new ToolNormalizer()]), + ), + ]); + + return new Agent( + $platform, + $model, + toolbox: new SchemaOnlyToolbox($tools), + toolExecutor: new DurableToolExecutor( + $environment, + $guard ?? new ModeToolGuard($tools), + $gate ?? new ToolApprovalGate(), + $desk ?? new HumanQuestionDesk(), + $watches ?? new WatchDesk(), + $mode ?? static fn(): AgentMode => AgentMode::Auto, + $humanTimeout, + $model, + ), + maxToolCalls: $maxToolCalls, + ); + } +} diff --git a/symfony/src/Ai/Durable/DurableModelClient.php b/symfony/src/Ai/Durable/DurableModelClient.php new file mode 100644 index 000000000..2d0cc6bb4 --- /dev/null +++ b/symfony/src/Ai/Durable/DurableModelClient.php @@ -0,0 +1,68 @@ +stub = $environment->activityStub(ModelInvocationActivityInterface::class, $options); + } + + public function supports(Model $model): bool + { + return true; + } + + public function request(Model $model, array|string $payload, array $options = []): RawResultInterface + { + if (!\is_array($payload)) { + throw new \InvalidArgumentException('Un payload textuel ne traverse pas la frontière d\'activité de ce prototype.'); + } + + if (true === ($options['stream'] ?? false)) { + // Une activité rend une valeur une fois : un flux de deltas ne se rejoue pas. + throw new \LogicException('Le streaming est incompatible avec le rejeu : journalise le résultat assemblé, streame sur un canal latéral.'); + } + + // Préventif : on ne part jamais au-dessus du plafond. La compaction est pure, donc elle + // rend le même payload à chaque rejeu. + $payload['messages'] = $this->budget->fit($payload['messages'] ?? []); + + $data = $this->environment->await($this->stub->invokeModel($model->getName(), $payload, $options)); + + // Réactif : le fournisseur a compté autrement que nous. Rejouer la même charge donnerait + // le même verdict — c'est la charge qu'il faut changer, pas l'appel qu'il faut retenter. + if (ContextOverflow::detected($data)) { + $payload['messages'] = $this->budget->halved()->fit($payload['messages']); + $data = $this->environment->await($this->stub->invokeModel($model->getName(), $payload, $options)); + } + + return new JournaledHttpResult($data); + } +} diff --git a/symfony/src/Ai/Durable/DurableToolExecutor.php b/symfony/src/Ai/Durable/DurableToolExecutor.php new file mode 100644 index 000000000..2589bf4a2 --- /dev/null +++ b/symfony/src/Ai/Durable/DurableToolExecutor.php @@ -0,0 +1,268 @@ +all(...)` paralléliserait les appels d'un même + * tour — à faire quand un tour a réellement plusieurs outils lents. + */ +final class DurableToolExecutor implements ToolExecutorInterface +{ + private readonly ActivityStub $stub; + + /** + * @param \Closure(): AgentMode $mode le mode est de l'état de workflow, il peut changer + * entre deux tours — donc il se lit au moment de la décision + * @param Duration|null $humanTimeout échéance de toute attente humaine — validation d'un + * outil comme réponse à une question — globale à + * l'instance d'agent ; `null` = attendre indéfiniment + */ + public function __construct( + private readonly WorkflowEnvironment $environment, + private readonly ToolGuardInterface $guard, + private readonly ToolApprovalGate $gate, + private readonly HumanQuestionDesk $desk, + private readonly WatchDesk $watches, + private readonly \Closure $mode, + private readonly ?Duration $humanTimeout = null, + private readonly string $model = 'gpt-4o-mini', + ?ActivityOptions $options = null, + ) { + $this->stub = $environment->activityStub(AgentToolActivityInterface::class, $options); + } + + public function execute(array $toolCalls): \Generator + { + $results = []; + + foreach ($toolCalls as $toolCall) { + $decision = $this->guard->decide($toolCall, ($this->mode)()); + + if ($decision->isDenied()) { + yield new Progress('tool_denied', (string) $decision->reason, $toolCall); + $results[] = new ToolResult($toolCall, \sprintf('Refusé : %s', $decision->reason)); + + continue; + } + + if ($decision->needsApproval()) { + $this->gate->ask($toolCall, (string) $decision->reason); + yield new Progress('tool_approval', (string) $decision->reason, $toolCall); + + // Suspension, pas attente : le processus peut mourir ici, l'accord peut arriver + // demain, le workflow reprendra à cette ligne. L'échéance est un minuteur du + // journal (DUR032), donc elle survit au redémarrage elle aussi. + try { + $this->environment->await( + fn(): bool => $this->gate->isSettled($toolCall->getId()), + $this->humanTimeout, + ); + } catch (DeadlineExceededException) { + // Pas de réponse vaut refus — mais l'issue reste distincte d'un refus humain : + // personne n'a rien décidé. Elle est inscrite dans la porte pour que le rejeu + // la relise au lieu de replanifier un minuteur déjà tiré. + $this->gate->timeout($toolCall->getId()); + yield new Progress('tool_expired', \sprintf('Validation de « %s » expirée.', $toolCall->getName()), $toolCall); + } + + $outcome = $this->gate->outcome($toolCall->getId()) ?? ApprovalOutcome::Refused; + if (!$outcome->isApproved()) { + $results[] = new ToolResult($toolCall, $outcome->message()); + + continue; + } + } + + // Le seul outil dont l'exécution est une suspension : son résultat n'est pas calculé, + // il est attendu. + if (AskUserQuestion::TOOL === $toolCall->getName()) { + $results[] = new ToolResult($toolCall, yield from $this->askHuman($toolCall)); + + continue; + } + + // La troisième : elle attend un autre agent. Le délégué est un workflow enfant — sa + // propre exécution, son propre journal, son propre modèle — et le parent se suspend + // sur sa réponse comme sur n'importe quel `await`. + if (DelegateTool::TOOL === $toolCall->getName()) { + $results[] = new ToolResult($toolCall, yield from $this->delegate($toolCall)); + + continue; + } + + // L'autre suspension : celle-ci n'attend pas un humain devant une carte, mais un + // événement du dehors. + if (WatchTool::TOOL === $toolCall->getName()) { + $results[] = new ToolResult($toolCall, yield from $this->standBy($toolCall)); + + continue; + } + + yield new Progress('tool_call', \sprintf('Exécution de l\'outil "%s".', $toolCall->getName()), $toolCall); + + $results[] = new ToolResult( + $toolCall, + $this->environment->await($this->stub->callTool($toolCall->getId(), $toolCall->getName(), $toolCall->getArguments())), + ); + } + + return $results; + } + + /** + * La délégation : un sous-agent fait le travail, le parent attend sa réponse. + * + * **Le plafond est le sujet de cette méthode.** Le délégué reçoit le mode effectif du parent + * comme plafond, et son propre mode ne peut plus le desserrer — ni à l'entrée, ni par un + * `set_mode` ensuite. Sans ça, un agent en `standard` confierait à un sous-agent en `auto` ce + * que sa garde lui refuse : la garde ne serait pas contournée, elle serait décorative. + * + * Le délégué ne reçoit **que** sa mission : ni le fil du parent, ni ses outils de suspension. + * Un sous-agent que personne ne regarde n'a rien à demander à un humain. + * + * @return \Generator + */ + private function delegate(ToolCall $toolCall): \Generator + { + $arguments = $toolCall->getArguments(); + $mission = trim((string) ($arguments['mission'] ?? '')); + if ('' === $mission) { + return 'Une délégation sans mission n\'a rien à déléguer. Dis ce que le sous-agent doit faire.'; + } + + $ceiling = ($this->mode)(); + $modele = trim((string) ($arguments['modele'] ?? '')) ?: $this->model; + + yield new Progress('delegated', \sprintf('Mission confiée à un sous-agent (%s).', $modele), $toolCall); + + // ⚠ Positionnel, et pas par noms. `ChildWorkflowStub::argumentsToInput()` apparie les + // arguments **par position** (`$arguments[$i]`) : 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 valeur par défaut. Sans exception, sans trace — le sous-agent démarre + // avec un prompt vide et attend un message qui ne viendra jamais. C'est un défaut du cœur, + // pas d'ici ; en attendant, l'ordre de la signature fait foi. + $reponse = (string) $this->environment->await( + $this->environment->childWorkflowStub(DurableAgentWorkflow::class)->run( + [], // tools + $modele, // model + $ceiling->value, // mode + $ceiling->value, // modeCeiling + DurableAgentWorkflow::SYSTEM_PROMPT, // systemPrompt + $mission, // prompt + 1, // maxTurns + ), + ); + + return \sprintf('Le sous-agent (%s) répond : %s', $modele, $reponse); + } + + /** + * La veille : l'agent dort jusqu'à l'alerte, et se réveille en retrouvant son intention. + * + * @return \Generator ce que le modèle relira à la place d'un résultat d'outil + */ + private function standBy(ToolCall $toolCall): \Generator + { + try { + $watch = Watch::fromArguments($toolCall->getId(), $toolCall->getArguments()); + } catch (UnknownWatchSubject $refus) { + // Rendu au modèle comme un résultat d'outil, pas levé : c'est une consigne mal suivie, + // pas une panne, et l'agent peut se corriger au tour suivant. Une veille armée sur un + // sujet inconnu, elle, dormirait jusqu'à son échéance sans que rien ne le dise. + yield new Progress('watch_refused', $refus->subject, $toolCall); + + return \sprintf( + '%s Sujets connus : %s. Reprends avec l\'un d\'eux, ou fais autrement.', + $refus->getMessage(), + implode(', ', WatchSubject::values()), + ); + } + + $this->watches->watch($watch); + yield new Progress('watch_started', $watch->observation, $toolCall); + + // L'échéance de la veille est celle que le modèle a demandée ; à défaut, le budget humain + // de l'agent — une veille sans borne aucune finirait par ne plus être une veille. + $deadline = Duration::fromWireValue($toolCall->getArguments()['deadlineSeconds'] ?? null) ?? $this->humanTimeout; + + try { + $this->environment->await(fn(): bool => $this->watches->isSettled($toolCall->getId()), $deadline); + } catch (DeadlineExceededException) { + $this->watches->raise($toolCall->getId(), ''); + yield new Progress('watch_expired', $watch->observation, $toolCall); + + return \sprintf( + 'La veille « %s » a expiré sans alerte. Tu comptais : %s. Reprends la main et dis ce que tu fais.', + $watch->observation, + $watch->intention, + ); + } + + // Le réveil rend l'observation **et** l'intention : c'est ce qui dispense l'agent de se + // souvenir de ce qu'il faisait il y a trois jours. + return \sprintf( + 'Alerte sur « %s » : %s. Tu comptais : %s.', + $watch->observation, + '' === $this->watches->observationOf($toolCall->getId()) ? 'rien de plus n’a été rapporté' : $this->watches->observationOf($toolCall->getId()), + $watch->intention, + ); + } + + /** + * @return \Generator ce que le modèle relira à la place d'un résultat d'outil + */ + private function askHuman(ToolCall $toolCall): \Generator + { + $question = PendingQuestion::fromArguments($toolCall->getId(), $toolCall->getArguments()); + $this->desk->ask($question); + yield new Progress('question_asked', $question->question, $toolCall); + + try { + $this->environment->await( + fn(): bool => $this->desk->isAnswered($toolCall->getId()), + $this->humanTimeout, + ); + } catch (DeadlineExceededException) { + // Comme pour une validation : l'absence de réponse est inscrite, sinon le rejeu + // replanifierait un minuteur déjà tiré. + $this->desk->answer($toolCall->getId(), []); + yield new Progress('question_expired', \sprintf('Question « %s » restée sans réponse.', $question->header), $toolCall); + } + + $answers = $this->desk->answersOf($toolCall->getId()); + + return [] === $answers + ? 'Aucune réponse : l’utilisateur n’a rien choisi avant l’échéance. Poursuis sans cette information, ou dis ce qui te manque.' + : implode(' ; ', $answers); + } +} diff --git a/symfony/src/Ai/Durable/SchemaOnlyToolbox.php b/symfony/src/Ai/Durable/SchemaOnlyToolbox.php new file mode 100644 index 000000000..6e4f24fd3 --- /dev/null +++ b/symfony/src/Ai/Durable/SchemaOnlyToolbox.php @@ -0,0 +1,52 @@ +tools = array_map( + static fn (ToolDefinition $definition): Tool => new Tool( + new ExecutionReference(DurableToolExecutor::class, 'execute'), + $definition->name, + $definition->description, + $definition->parameters, + ), + $tools->definitions, + ); + } + + public function getTools(): array + { + return $this->tools; + } + + public function execute(ToolCall $toolCall): ToolResult + { + // `Runner` passe par le ToolExecutor, jamais par ici. Si ce chemin s'ouvre un jour, il + // exécuterait l'outil en code workflow — hors journal, donc rejoué à chaque reprise. + throw new \LogicException(\sprintf('L\'exécution appartient à %s, pas au toolbox.', DurableToolExecutor::class)); + } +} diff --git a/symfony/src/Ai/Guard/AgentMode.php b/symfony/src/Ai/Guard/AgentMode.php new file mode 100644 index 000000000..d8aac6fa5 --- /dev/null +++ b/symfony/src/Ai/Guard/AgentMode.php @@ -0,0 +1,83 @@ + false, + self::Edition => $effect->isIrreversible(), + self::Standard => !$effect->isHarmless(), + }; + } + + /** + * Combien ce mode laisse passer. Les trois cas sont un ordre total, et il vit ici pour la même + * raison que {@see requiresApprovalFor()} : c'est une propriété du mode, pas une comparaison + * que chaque appelant refait à sa façon. + */ + private function permissiveness(): int + { + return match ($this) { + self::Standard => 0, + self::Edition => 1, + self::Auto => 2, + }; + } + + /** + * Le plus strict de plusieurs modes — **l'autorité ne grandit pas par délégation**. + * + * Un agent délégué prend le plus strict de sa chaîne. Sans ça, déléguer serait 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. + */ + public static function strictest(self ...$modes): self + { + $strict = self::Auto; + foreach ($modes as $mode) { + if ($mode->permissiveness() < $strict->permissiveness()) { + $strict = $mode; + } + } + + return $strict; + } + + /** + * Ce mode desserre-t-il le plafond ? C'est la question que pose un `set_mode` reçu en cours de + * route — le plafond vaut à l'entrée **et** après, sinon un sous-agent le lèverait d'un signal. + */ + public function loosens(self $ceiling): bool + { + return $this->permissiveness() > $ceiling->permissiveness(); + } +} diff --git a/symfony/src/Ai/Guard/ApprovalOutcome.php b/symfony/src/Ai/Guard/ApprovalOutcome.php new file mode 100644 index 000000000..a8691bc21 --- /dev/null +++ b/symfony/src/Ai/Guard/ApprovalOutcome.php @@ -0,0 +1,47 @@ + '', + self::Refused => 'Refusé par l’utilisateur.', + self::Expired => 'Refusé : aucune validation reçue avant l’échéance.', + }; + } +} diff --git a/symfony/src/Ai/Guard/ModeToolGuard.php b/symfony/src/Ai/Guard/ModeToolGuard.php new file mode 100644 index 000000000..ccf69c30d --- /dev/null +++ b/symfony/src/Ai/Guard/ModeToolGuard.php @@ -0,0 +1,44 @@ + $denied outils refusés quel que soit le mode + */ + public function __construct( + private readonly Toolset $tools = new Toolset(), + private readonly array $denied = [], + ) { + } + + public function decide(ToolCall $toolCall, AgentMode $mode): ToolDecision + { + $name = $toolCall->getName(); + + if (\in_array($name, $this->denied, true)) { + return ToolDecision::deny(\sprintf('L\'outil « %s » est interdit par la politique de l\'agent.', $name)); + } + + $effect = $this->tools->effectOf($name); + + return $mode->requiresApprovalFor($effect) + ? ToolDecision::ask(\sprintf('« %s » (%s) demande une validation en mode %s.', $name, $effect->value, $mode->value)) + : ToolDecision::allow(); + } +} diff --git a/symfony/src/Ai/Guard/PendingApproval.php b/symfony/src/Ai/Guard/PendingApproval.php new file mode 100644 index 000000000..43662c981 --- /dev/null +++ b/symfony/src/Ai/Guard/PendingApproval.php @@ -0,0 +1,54 @@ + $arguments + */ + /** + * @param array $arguments + * @param float|null $expiresAt instant (epoch, secondes) où l'échéance tranchera à la + * place de l'humain ; `null` = pas d'échéance + */ + public function __construct( + public string $callId, + public string $tool, + public array $arguments, + public string $reason, + public ?float $expiresAt = null, + ) { + } + + public function expiringAt(?float $expiresAt): self + { + return new self($this->callId, $this->tool, $this->arguments, $this->reason, $expiresAt); + } + + public static function of(ToolCall $toolCall, string $reason): self + { + return new self($toolCall->getId(), $toolCall->getName(), $toolCall->getArguments(), $reason); + } + + /** + * @return array{callId: string, tool: string, arguments: array, reason: string, expiresAt: float|null} + */ + public function jsonSerialize(): array + { + return [ + 'callId' => $this->callId, + 'tool' => $this->tool, + 'arguments' => $this->arguments, + 'reason' => $this->reason, + 'expiresAt' => $this->expiresAt, + ]; + } +} diff --git a/symfony/src/Ai/Guard/ToolApprovalGate.php b/symfony/src/Ai/Guard/ToolApprovalGate.php new file mode 100644 index 000000000..f65d92d0b --- /dev/null +++ b/symfony/src/Ai/Guard/ToolApprovalGate.php @@ -0,0 +1,73 @@ + */ + private array $outcomes = []; + + /** @var array */ + private array $pending = []; + + public function ask(ToolCall $toolCall, string $reason): void + { + $this->pending[$toolCall->getId()] = PendingApproval::of($toolCall, $reason); + } + + /** + * Une décision humaine, arrivée par signal. + */ + public function decide(string $callId, bool $approved): void + { + $this->settle($callId, $approved ? ApprovalOutcome::Approved : ApprovalOutcome::Refused); + } + + /** + * L'échéance a tranché faute de réponse. Distinct d'un refus : personne n'a rien décidé. + */ + public function timeout(string $callId): void + { + $this->settle($callId, ApprovalOutcome::Expired); + } + + public function isSettled(string $callId): bool + { + return \array_key_exists($callId, $this->outcomes); + } + + public function outcome(string $callId): ?ApprovalOutcome + { + return $this->outcomes[$callId] ?? null; + } + + /** + * @return list + */ + public function pending(): array + { + return array_values($this->pending); + } + + private function settle(string $callId, ApprovalOutcome $outcome): void + { + // La première issue gagne : un signal arrivé après le tir de l'échéance ne doit pas + // ressusciter un appel que le workflow a déjà tranché — au rejeu, l'ordre du journal + // rejouerait l'inverse. + $this->outcomes[$callId] ??= $outcome; + unset($this->pending[$callId]); + } +} diff --git a/symfony/src/Ai/Guard/ToolDecision.php b/symfony/src/Ai/Guard/ToolDecision.php new file mode 100644 index 000000000..2bce3a66e --- /dev/null +++ b/symfony/src/Ai/Guard/ToolDecision.php @@ -0,0 +1,57 @@ +verdict; + } + + /** + * L'appel suspend l'exécution jusqu'à une décision humaine — ou jusqu'à l'échéance. + */ + public function needsApproval(): bool + { + return ToolVerdict::Ask === $this->verdict; + } + + /** + * L'appel ne partira pas, quel que soit le mode : la politique l'interdit. + */ + public function isDenied(): bool + { + return ToolVerdict::Deny === $this->verdict; + } + + public static function allow(): self + { + return new self(ToolVerdict::Allow, null); + } + + public static function ask(string $reason): self + { + return new self(ToolVerdict::Ask, $reason); + } + + public static function deny(string $reason): self + { + return new self(ToolVerdict::Deny, $reason); + } +} diff --git a/symfony/src/Ai/Guard/ToolEffect.php b/symfony/src/Ai/Guard/ToolEffect.php new file mode 100644 index 000000000..3567e9481 --- /dev/null +++ b/symfony/src/Ai/Guard/ToolEffect.php @@ -0,0 +1,37 @@ +hub->publish(new Update( + self::topicFor($executionId), + json_encode(['executionId' => $executionId], \JSON_THROW_ON_ERROR), + )); + } catch (\Throwable $exception) { + $this->logger?->debug('Le concentrateur Mercure n\'a pas pris la sonnerie : {message}', [ + 'message' => $exception->getMessage(), + 'executionId' => $executionId, + ]); + } + } +} diff --git a/symfony/src/Ai/Live/AgentSignals.php b/symfony/src/Ai/Live/AgentSignals.php new file mode 100644 index 000000000..cfa59050b --- /dev/null +++ b/symfony/src/Ai/Live/AgentSignals.php @@ -0,0 +1,45 @@ + $payload + */ + public function send(string $executionId, string $signalName, array $payload): void + { + if (null !== $this->workflowClient) { + $this->workflowClient->signal($this->workflowClient->workflowId($executionId), $signalName, $payload); + } else { + $this->messageBus->dispatch(new DeliverWorkflowSignalMessage($executionId, $signalName, $payload)); + } + + $this->feed->nudge($executionId); + } +} diff --git a/symfony/src/Ai/Platform/ChatCompletion.php b/symfony/src/Ai/Platform/ChatCompletion.php new file mode 100644 index 000000000..81329deee --- /dev/null +++ b/symfony/src/Ai/Platform/ChatCompletion.php @@ -0,0 +1,105 @@ + $toolCalls + */ + private function __construct( + public ?string $text, + public ?string $reasoning, + public array $toolCalls, + ) { + } + + /** + * `null` quand le journal ne porte pas de réponse — et c'est une information, pas un défaut : + * c'est elle qui distingue un tour en cours d'un tour fini. + */ + public static function fromWire(mixed $result): ?self + { + $message = \is_array($result) ? $result['choices'][0]['message'] ?? null : null; + if (!\is_array($message)) { + return null; + } + + $sidecar = $message['reasoning_content'] ?? null; + [$text, $reasoning] = TranscriptMessage::splitContent( + $message['content'] ?? null, + \is_string($sidecar) ? $sidecar : null, + ); + + $calls = $message['tool_calls'] ?? null; + + return new self($text, $reasoning, array_map( + ToolCallRef::fromWire(...), + array_values(\is_array($calls) ? $calls : []), + )); + } + + public static function ofText(string $text, ?string $reasoning = null): self + { + return new self($text, $reasoning, []); + } + + public static function ofToolCall(ToolCallRef $call): self + { + return new self(null, null, [$call]); + } + + /** + * La forme exacte qu'attend le convertisseur du pont — c'est du fil, il part tel quel. + * + * Le raisonnement s'écrit en morceaux `thinking`/`text` et non dans un `reasoning_content` à + * côté : c'est la forme de Mistral, et `splitContent()` lit les deux ({@see TranscriptMessage}). + * Sans raisonnement on garde la chaîne simple qu'attendent tous les autres modèles. + * + * @return array + */ + public function toWire(): array + { + $message = ['content' => null === $this->reasoning ? $this->text : [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => $this->reasoning]]], + ['type' => 'text', 'text' => (string) $this->text], + ]]; + + if ([] !== $this->toolCalls) { + $message['tool_calls'] = array_map(static fn (ToolCallRef $call): array => [ + 'id' => $call->callId, + 'type' => 'function', + 'function' => [ + 'name' => $call->tool, + 'arguments' => json_encode($call->arguments, \JSON_UNESCAPED_UNICODE), + ], + ], $this->toolCalls); + } + + return ['choices' => [[ + 'message' => $message, + 'finish_reason' => [] === $this->toolCalls ? 'stop' : 'tool_calls', + ]]]; + } +} diff --git a/symfony/src/Ai/Platform/JournaledHttpResponse.php b/symfony/src/Ai/Platform/JournaledHttpResponse.php new file mode 100644 index 000000000..b5bb26657 --- /dev/null +++ b/symfony/src/Ai/Platform/JournaledHttpResponse.php @@ -0,0 +1,64 @@ += 400. + */ +final readonly class JournaledHttpResponse implements ResponseInterface +{ + /** + * @param array $data + */ + public function __construct( + private array $data, + ) { + } + + public function getStatusCode(): int + { + return 200; + } + + /** + * @return array> + */ + public function getHeaders(bool $throw = true): array + { + return ['content-type' => ['application/json']]; + } + + public function getContent(bool $throw = true): string + { + return json_encode($this->data, \JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + public function toArray(bool $throw = true): array + { + return $this->data; + } + + public function cancel(): void + { + // Rien à annuler : l'appel a eu lieu dans l'activité, peut-être il y a trois jours. + } + + public function getInfo(?string $type = null): mixed + { + $info = ['http_code' => 200, 'url' => 'durable://journal']; + + return null === $type ? $info : ($info[$type] ?? null); + } +} diff --git a/symfony/src/Ai/Platform/JournaledHttpResult.php b/symfony/src/Ai/Platform/JournaledHttpResult.php new file mode 100644 index 000000000..929b94071 --- /dev/null +++ b/symfony/src/Ai/Platform/JournaledHttpResult.php @@ -0,0 +1,47 @@ += 400, de sorte que la politique de retentative de Durable s'applique à + * un 429 ou un 503 (DUR011) au lieu de laisser le code workflow s'étrangler dessus. D'où le 200 + * en dur : c'est la seule issue qui atteigne jamais le rejeu. + */ +final readonly class JournaledHttpResult implements RawResultInterface +{ + /** + * @param array $data + */ + public function __construct( + private array $data, + ) { + } + + public function getData(): array + { + return $this->data; + } + + public function getDataStream(): iterable + { + // Une activité rend une valeur une fois : un flux de deltas ne se rejoue pas. + yield from []; + } + + public function getObject(): object + { + return new JournaledHttpResponse($this->data); + } +} diff --git a/symfony/src/Ai/Platform/ModelClientFactory.php b/symfony/src/Ai/Platform/ModelClientFactory.php new file mode 100644 index 000000000..8bf47d9f3 --- /dev/null +++ b/symfony/src/Ai/Platform/ModelClientFactory.php @@ -0,0 +1,34 @@ +isCompactionRequest($messages)) { + return new InMemoryRawResult($this->text($this->digest($messages))); + } + + $lastUser = ''; + $answeredSinceUser = 0; + + foreach ($messages as $message) { + if ('user' === ($message['role'] ?? null)) { + $lastUser = mb_strtolower((string) $message['content']); + $answeredSinceUser = 0; + } elseif ('tool' === ($message['role'] ?? null)) { + ++$answeredSinceUser; + } + } + + // Un seul tour d'outil par message : au second passage, on répond. + if ($answeredSinceUser > 0) { + return new InMemoryRawResult($this->text( + $this->summarise($messages), + 'L’outil a répondu ; je rends son relevé tel quel plutôt que de le paraphraser.', + )); + } + + // Une demande vague : le modèle ne devine pas, il demande. C'est ce que fait un vrai + // modèle quand la consigne laisse plusieurs suites également raisonnables. + if (str_contains($lastUser, 'import')) { + return new InMemoryRawResult($this->toolCall($messages, AskUserQuestion::TOOL, [ + 'question' => 'Comment veux-tu lancer cet import ?', + 'header' => 'Mode d’import', + 'options' => [ + ['label' => 'Par lot', 'description' => 'Tout d’un coup, plus rapide, bloque le catalogue'], + ['label' => 'Au fil de l’eau', 'description' => 'Plus lent, le catalogue reste servi'], + ['label' => 'Simulation', 'description' => 'Rien n’est écrit, on regarde ce qui changerait'], + ], + ])); + } + + if (str_contains($lastUser, 'surveille') || str_contains($lastUser, 'préviens')) { + return new InMemoryRawResult($this->toolCall($messages, WatchTool::TOOL, [ + // Le sujet est ce qui rend la veille joignable : `app:agent:evenement + // commande.expediee` la réveille, un texte libre ne l'aurait jamais réveillée. + 'sujet' => WatchSubject::CommandeExpediee->value, + 'observation' => 'La livraison du fournisseur arrive à l’entrepôt', + 'intention' => 'Enregistrer une note de réception et prévenir l’équipe', + 'deadlineSeconds' => 900, + ])); + } + + // Le levier explicite : un vrai modèle décide seul de demander, un modèle scripté a besoin + // qu'on le lui dise. « demande-moi… » sert à voir le questionnaire à volonté, et « choix + // multiple » à voir l'autre forme. + if (str_contains($lastUser, 'demande') || str_contains($lastUser, 'question')) { + return new InMemoryRawResult($this->toolCall($messages, AskUserQuestion::TOOL, [ + 'question' => 'Sur quoi veux-tu que je tranche ?', + 'header' => 'À toi de voir', + 'multiSelect' => str_contains($lastUser, 'multiple'), + 'options' => [ + ['label' => 'La météo', 'description' => 'Je consulte, personne n’a rien à valider'], + ['label' => 'Une note', 'description' => 'J’écris dans le dossier courant'], + ['label' => 'Un courriel', 'description' => 'Effet externe : la garde demandera ton accord'], + ], + ])); + } + + if (str_contains($lastUser, 'mail') || str_contains($lastUser, 'courriel')) { + return new InMemoryRawResult($this->toolCall($messages, 'send_email', [ + 'to' => 'equipe@example.test', + 'body' => 'Compte rendu demandé depuis le chat durable.', + ])); + } + + // Le levier de la délégation : un modèle scripté ne décide pas seul de confier une tâche. + if (str_contains($lastUser, 'délègue') || str_contains($lastUser, 'delegue') || str_contains($lastUser, 'équipe')) { + return new InMemoryRawResult($this->toolCall($messages, DelegateTool::TOOL, [ + 'mission' => 'Résume en une phrase ce que fait un agent durable.', + 'modele' => 'ministral-3b-latest', + ])); + } + + if (str_contains($lastUser, 'note')) { + return new InMemoryRawResult($this->toolCall($messages, 'save_note', ['text' => $lastUser])); + } + + foreach (['paris', 'lyon', 'marseille'] as $city) { + if (str_contains($lastUser, $city)) { + return new InMemoryRawResult($this->toolCall($messages, 'weather', ['city' => ucfirst($city)])); + } + } + + return new InMemoryRawResult($this->text( + 'Je sais consulter la météo d’une ville, enregistrer une note, ou envoyer un courriel. Lequel ?' + )); + } + + /** + * La compaction arrive par la même porte que le reste — c'est sa consigne système qui la + * distingue, comme elle le ferait chez un vrai fournisseur. + * + * @param list> $messages + */ + private function isCompactionRequest(array $messages): bool + { + foreach ($messages as $message) { + if ('system' === ($message['role'] ?? null) + && str_contains((string) $message['content'], 'Résume-la') + ) { + return true; + } + } + + return false; + } + + /** + * Un résumé pour de faux, mais qui dit vrai : ce qui a été demandé, et où la conversation en + * était restée. Fonction pure de la conversation reçue, donc rejouable. + * + * @param list> $messages + */ + private function digest(array $messages): string + { + $asked = []; + $lastAnswer = ''; + foreach ($messages as $message) { + $role = $message['role'] ?? null; + if ('user' === $role) { + $asked[] = trim((string) $message['content']); + } elseif ('assistant' === $role && null !== ($message['content'] ?? null)) { + $lastAnswer = trim((string) $message['content']); + } + } + + if ([] === $asked) { + return 'La conversation précédente n’a pas dépassé les présentations.'; + } + + return \sprintf( + 'La personne avait demandé : %s. Dernière réponse donnée : « %s ». Rien n’est resté en attente.', + implode(', ', array_map(static fn (string $q): string => '« ' . $q . ' »', $asked)), + '' === $lastAnswer ? 'aucune' : $lastAnswer, + ); + } + + /** + * @param list> $messages + */ + private function summarise(array $messages): string + { + $last = ''; + foreach ($messages as $message) { + if ('tool' === ($message['role'] ?? null)) { + $last = (string) $message['content']; + } + } + + return '' === $last ? 'C’est fait.' : $last; + } + + /** + * L'identifiant d'appel dérive du rang du tour : deux constructions de la même conversation + * donnent le même identifiant, sinon le rejeu divergerait. + * + * @param list> $messages + * @param array $arguments + * + * @return array + */ + private function toolCall(array $messages, string $tool, array $arguments): array + { + return ChatCompletion::ofToolCall( + new ToolCallRef(\sprintf('call_%d', \count($messages)), $tool, $arguments), + )->toWire(); + } + + /** + * Le texte, avec son raisonnement s'il y en a un. La forme des deux vit dans + * {@see ChatCompletion::toWire()} — la même que celle que relit la projection. + * + * @return array + */ + private function text(string $text, string $reasoning = ''): array + { + return ChatCompletion::ofText($text, '' === $reasoning ? null : $reasoning)->toWire(); + } +} diff --git a/symfony/src/Ai/Question/AskUserQuestion.php b/symfony/src/Ai/Question/AskUserQuestion.php new file mode 100644 index 000000000..043401e7b --- /dev/null +++ b/symfony/src/Ai/Question/AskUserQuestion.php @@ -0,0 +1,69 @@ + 'object', + 'properties' => [ + 'question' => [ + 'type' => 'string', + 'description' => 'La question, formulée pour être lue telle quelle.', + ], + 'header' => [ + 'type' => 'string', + 'description' => 'Étiquette courte qui dit de quoi il s’agit (quelques mots).', + ], + 'options' => [ + 'type' => 'array', + 'description' => 'Les suites possibles. Deux à quatre, distinctes, la recommandée en premier.', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'label' => ['type' => 'string', 'description' => 'Le choix, en quelques mots.'], + 'description' => ['type' => 'string', 'description' => 'Ce que ce choix implique.'], + ], + 'required' => ['label'], + ], + ], + 'multiSelect' => [ + 'type' => 'boolean', + 'description' => 'Vrai si plusieurs réponses peuvent être retenues ensemble.', + ], + ], + 'required' => ['question', 'options'], + ], + ); + } +} diff --git a/symfony/src/Ai/Question/HumanQuestionDesk.php b/symfony/src/Ai/Question/HumanQuestionDesk.php new file mode 100644 index 000000000..4921b3133 --- /dev/null +++ b/symfony/src/Ai/Question/HumanQuestionDesk.php @@ -0,0 +1,62 @@ +> id d'appel → réponses retenues */ + private array $answers = []; + + /** @var array */ + private array $pending = []; + + public function ask(PendingQuestion $question): void + { + $this->pending[$question->callId] = $question; + } + + /** + * @param list $answers + */ + public function answer(string $callId, array $answers): void + { + // La première réponse gagne : un second signal arrivé après l'échéance ne doit pas + // rouvrir une question déjà close, sinon l'ordre du journal donnerait le verdict inverse + // au rejeu. + $this->answers[$callId] ??= array_values(array_filter( + array_map(static fn (mixed $answer): string => trim((string) $answer), $answers), + static fn (string $answer): bool => '' !== $answer, + )); + unset($this->pending[$callId]); + } + + public function isAnswered(string $callId): bool + { + return \array_key_exists($callId, $this->answers); + } + + /** + * @return list + */ + public function answersOf(string $callId): array + { + return $this->answers[$callId] ?? []; + } + + /** + * @return list + */ + public function pending(): array + { + return array_values($this->pending); + } +} diff --git a/symfony/src/Ai/Question/PendingQuestion.php b/symfony/src/Ai/Question/PendingQuestion.php new file mode 100644 index 000000000..430b2c4b0 --- /dev/null +++ b/symfony/src/Ai/Question/PendingQuestion.php @@ -0,0 +1,77 @@ + $options + * @param float|null $expiresAt instant (epoch) où l'échéance répondra « rien » à la + * place de l'humain + */ + public function __construct( + public string $callId, + public string $question, + public string $header, + public array $options, + public bool $multiSelect = false, + public ?float $expiresAt = null, + ) { + } + + /** + * Fabrique de frontière : les arguments viennent du modèle, donc en tableaux, et rien ne + * garantit qu'il ait respecté le schéma. Une option sans libellé est jetée plutôt que de + * faire échouer le tour. + * + * @param array $arguments + */ + public static function fromArguments(string $callId, array $arguments, ?float $expiresAt = null): self + { + $proposed = \is_array($arguments['options'] ?? null) ? $arguments['options'] : []; + $options = []; + foreach ($proposed as $option) { + if (\is_array($option) && '' !== trim((string) ($option['label'] ?? ''))) { + $options[] = QuestionOption::fromWire($option); + } + } + + return new self( + $callId, + (string) ($arguments['question'] ?? ''), + (string) ($arguments['header'] ?? ''), + $options, + (bool) ($arguments['multiSelect'] ?? false), + $expiresAt, + ); + } + + public function expiringAt(?float $expiresAt): self + { + return new self($this->callId, $this->question, $this->header, $this->options, $this->multiSelect, $expiresAt); + } + + /** + * @return array{callId: string, question: string, header: string, options: list, multiSelect: bool, expiresAt: float|null} + */ + public function jsonSerialize(): array + { + return [ + 'callId' => $this->callId, + 'question' => $this->question, + 'header' => $this->header, + 'options' => $this->options, + 'multiSelect' => $this->multiSelect, + 'expiresAt' => $this->expiresAt, + ]; + } +} diff --git a/symfony/src/Ai/Question/QuestionOption.php b/symfony/src/Ai/Question/QuestionOption.php new file mode 100644 index 000000000..bf3e3e9c5 --- /dev/null +++ b/symfony/src/Ai/Question/QuestionOption.php @@ -0,0 +1,37 @@ + $wire + */ + public static function fromWire(array $wire): self + { + return new self((string) ($wire['label'] ?? ''), (string) ($wire['description'] ?? '')); + } + + /** + * @return array{label: string, description: string} + */ + public function jsonSerialize(): array + { + return ['label' => $this->label, 'description' => $this->description]; + } +} diff --git a/symfony/src/Ai/Team/DelegateTool.php b/symfony/src/Ai/Team/DelegateTool.php new file mode 100644 index 000000000..00df49315 --- /dev/null +++ b/symfony/src/Ai/Team/DelegateTool.php @@ -0,0 +1,64 @@ + 'object', + 'properties' => [ + 'mission' => [ + 'type' => 'string', + 'description' => 'Ce que le sous-agent doit faire, en entier : il ne voit ' + .'rien de votre conversation, seulement cette phrase.', + ], + 'modele' => [ + 'type' => 'string', + 'description' => 'Le modèle à lui confier. À omettre pour reprendre le tien.', + ], + ], + 'required' => ['mission'], + ], + ); + } +} diff --git a/symfony/src/Ai/Tool/SaveNoteTool.php b/symfony/src/Ai/Tool/SaveNoteTool.php new file mode 100644 index 000000000..253ff8519 --- /dev/null +++ b/symfony/src/Ai/Tool/SaveNoteTool.php @@ -0,0 +1,19 @@ + 'save_note'])] +final class SaveNoteTool +{ + /** + * @param array $arguments + */ + public function __invoke(array $arguments): string + { + return \sprintf('Note enregistrée (%d caractères).', \strlen((string) ($arguments['text'] ?? ''))); + } +} diff --git a/symfony/src/Ai/Tool/SendEmailTool.php b/symfony/src/Ai/Tool/SendEmailTool.php new file mode 100644 index 000000000..57d0e9df2 --- /dev/null +++ b/symfony/src/Ai/Tool/SendEmailTool.php @@ -0,0 +1,23 @@ + 'send_email'])] +final class SendEmailTool +{ + /** + * @param array $arguments + */ + public function __invoke(array $arguments): string + { + return \sprintf('Courriel envoyé à %s.', (string) ($arguments['to'] ?? '?')); + } +} diff --git a/symfony/src/Ai/Tool/ToolDefinition.php b/symfony/src/Ai/Tool/ToolDefinition.php new file mode 100644 index 000000000..b3bad5ab1 --- /dev/null +++ b/symfony/src/Ai/Tool/ToolDefinition.php @@ -0,0 +1,64 @@ +|null $parameters schéma JSON, tel qu'il part au fournisseur + */ + public function __construct( + public string $name, + public string $description, + public ToolEffect $effect = ToolEffect::External, + public ?array $parameters = null, + ) { + if ('' === trim($name)) { + throw new \InvalidArgumentException('Un outil doit avoir un nom.'); + } + } + + /** + * Fabrique de frontière : la charge du workflow arrive du journal, donc en tableaux. + * + * Un outil qui ne déclare pas son effet est traité comme externe — le défaut prudent, celui qui + * demande une validation dans tous les modes sauf `auto`. + * + * @param array{description?: string, effect?: string, parameters?: array|null} $wire + */ + public static function fromWire(string $name, array $wire): self + { + return new self( + $name, + (string) ($wire['description'] ?? ''), + ToolEffect::tryFrom((string) ($wire['effect'] ?? '')) ?? ToolEffect::External, + $wire['parameters'] ?? null, + ); + } + + /** + * Retour vers le fil : la charge de démarrage du workflow part en JSON, indexée par nom + * d'outil — c'est {@see Toolset::toWire()} qui pose la clé. + * + * @return array{description: string, effect: string, parameters: array|null} + */ + public function toWire(): array + { + return [ + 'description' => $this->description, + 'effect' => $this->effect->value, + 'parameters' => $this->parameters, + ]; + } +} diff --git a/symfony/src/Ai/Tool/Toolset.php b/symfony/src/Ai/Tool/Toolset.php new file mode 100644 index 000000000..264bad968 --- /dev/null +++ b/symfony/src/Ai/Tool/Toolset.php @@ -0,0 +1,96 @@ + */ + public array $definitions; + + public function __construct(ToolDefinition ...$definitions) + { + $this->definitions = array_values($definitions); + } + + /** + * La charge du workflow arrive du journal, donc en tableaux. + * + * @param array|null}> $wire + */ + public static function fromWire(array $wire): self + { + $definitions = []; + foreach ($wire as $name => $definition) { + $definitions[] = ToolDefinition::fromWire((string) $name, $definition); + } + + return new self(...$definitions); + } + + /** + * @return array|null}> + */ + public function toWire(): array + { + $wire = []; + foreach ($this->definitions as $definition) { + $wire[$definition->name] = $definition->toWire(); + } + + return $wire; + } + + public function with(ToolDefinition ...$more): self + { + return new self(...$this->definitions, ...$more); + } + + /** + * Ce que fait un outil — la seule question que la garde pose à ce catalogue. + * + * Un outil inconnu est externe : le défaut prudent, celui qui demande une validation dans tous + * les modes sauf `auto`. + */ + public function effectOf(string $name): ToolEffect + { + // ponytail: balayage linéaire. Un agent porte une poignée d'outils ; le jour où il en + // porte cent, une map construite au constructeur. + foreach ($this->definitions as $definition) { + if ($definition->name === $name) { + return $definition->effect; + } + } + + return ToolEffect::External; + } + + public function count(): int + { + return \count($this->definitions); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->definitions); + } +} diff --git a/symfony/src/Ai/Tool/WeatherTool.php b/symfony/src/Ai/Tool/WeatherTool.php new file mode 100644 index 000000000..2d01b1d3c --- /dev/null +++ b/symfony/src/Ai/Tool/WeatherTool.php @@ -0,0 +1,26 @@ + 'weather'])] +final class WeatherTool +{ + private const RELEVES = ['Paris' => '22°C, ensoleillé', 'Lyon' => '25°C, nuageux']; + + /** + * @param array $arguments + */ + public function __invoke(array $arguments): string + { + $city = (string) ($arguments['city'] ?? ''); + + return \sprintf('%s : %s', $city, self::RELEVES[$city] ?? 'relevé indisponible'); + } +} diff --git a/symfony/src/Ai/Watch/BusinessEvent.php b/symfony/src/Ai/Watch/BusinessEvent.php new file mode 100644 index 000000000..da72e563f --- /dev/null +++ b/symfony/src/Ai/Watch/BusinessEvent.php @@ -0,0 +1,44 @@ + $details + */ + public function __construct( + public WatchSubject $subject, + public array $details = [], + ) { + } + + /** + * Ce que l'agent lit au réveil. Pas de JSON : c'est un modèle qui va le relire. + */ + public function describe(): string + { + if ([] === $this->details) { + return $this->subject->describe(); + } + + return \sprintf( + '%s (%s)', + $this->subject->describe(), + implode(', ', array_map( + static fn (string $key, mixed $value): string => \sprintf('%s : %s', $key, var_export($value, true)), + array_keys($this->details), + $this->details, + )), + ); + } +} diff --git a/symfony/src/Ai/Watch/UnknownWatchSubject.php b/symfony/src/Ai/Watch/UnknownWatchSubject.php new file mode 100644 index 000000000..4ccaff47a --- /dev/null +++ b/symfony/src/Ai/Watch/UnknownWatchSubject.php @@ -0,0 +1,21 @@ + $arguments + */ + public static function fromArguments(string $callId, array $arguments, ?float $expiresAt = null): self + { + $subject = WatchSubject::tryFrom(trim((string) ($arguments['sujet'] ?? ''))); + if (null === $subject) { + // Refus visible plutôt que veille morte : sans sujet connu, aucun événement ne pourra + // jamais lever cette veille, et l'agent dormirait jusqu'à son échéance sans que rien + // ne le signale. + throw new UnknownWatchSubject(trim((string) ($arguments['sujet'] ?? ''))); + } + + return new self( + $callId, + $subject, + trim((string) ($arguments['observation'] ?? '')), + trim((string) ($arguments['intention'] ?? '')), + $expiresAt, + ); + } + + /** + * @return array{callId: string, subject: string, observation: string, intention: string, expiresAt: float|null} + */ + public function jsonSerialize(): array + { + return [ + 'callId' => $this->callId, + 'subject' => $this->subject->value, + 'observation' => $this->observation, + 'intention' => $this->intention, + 'expiresAt' => $this->expiresAt, + ]; + } +} diff --git a/symfony/src/Ai/Watch/WatchDesk.php b/symfony/src/Ai/Watch/WatchDesk.php new file mode 100644 index 000000000..dee62c9e2 --- /dev/null +++ b/symfony/src/Ai/Watch/WatchDesk.php @@ -0,0 +1,58 @@ + id d'appel → observation rapportée par l'alerte */ + private array $alertes = []; + + /** @var array */ + private array $pending = []; + + public function watch(Watch $watch): void + { + $this->pending[$watch->callId] = $watch; + } + + /** + * Une alerte levée de l'extérieur : un webhook, une supervision, un humain, un autre agent. + */ + public function raise(string $callId, string $observation): void + { + // La première alerte gagne : une seconde arrivée après l'échéance ne doit pas rouvrir une + // veille déjà close, sinon l'ordre du journal donnerait le verdict inverse au rejeu. + $this->alertes[$callId] ??= trim($observation); + unset($this->pending[$callId]); + } + + public function isSettled(string $callId): bool + { + return \array_key_exists($callId, $this->alertes); + } + + public function observationOf(string $callId): string + { + return $this->alertes[$callId] ?? ''; + } + + /** + * @return list + */ + public function pending(): array + { + return array_values($this->pending); + } +} diff --git a/symfony/src/Ai/Watch/WatchRouter.php b/symfony/src/Ai/Watch/WatchRouter.php new file mode 100644 index 000000000..b00004cbd --- /dev/null +++ b/symfony/src/Ai/Watch/WatchRouter.php @@ -0,0 +1,112 @@ + les exécutions réveillées, pour que l'appelant puisse le dire + */ + public function dispatch(BusinessEvent $event, int $scanLimit = 100): array + { + $reveilles = []; + + foreach ($this->runningAgents($scanLimit) as $executionId) { + foreach ($this->transcript->forExecution($executionId)->watches as $watch) { + if ($watch->subject !== $event->subject) { + continue; + } + + // Une veille dont l'échéance est passée mais dont le minuteur n'a pas encore tiré + // est encore ici. La réveiller n'est pas un problème : `WatchDesk::raise()` est + // premier arrivé premier servi, et l'expiration qui suit ne rouvre rien. + $this->signals->send($executionId, 'alerte', [ + 'callId' => $watch->callId, + 'observation' => $event->describe(), + ]); + $reveilles[] = $executionId; + } + } + + return $reveilles; + } + + /** + * @return list + */ + private function runningAgents(int $limit): array + { + if (null === $this->catalog) { + return []; + } + + $executions = []; + foreach ($this->catalog->listRuns(WorkflowRunStatus::Running, limit: $limit)->runs as $run) { + if (DurableAgentWorkflow::TYPE !== $run->workflowName) { + continue; + } + + $executionId = self::executionIdOf($run); + if (null !== $executionId) { + $executions[] = $executionId; + } + } + + return $executions; + } + + private static function executionIdOf(WorkflowRunDescription $run): ?string + { + $workflowId = (string) $run->groupId; + + return str_starts_with($workflowId, self::TEMPORAL_PREFIX) + ? substr($workflowId, \strlen(self::TEMPORAL_PREFIX)) + : null; + } +} diff --git a/symfony/src/Ai/Watch/WatchSubject.php b/symfony/src/Ai/Watch/WatchSubject.php new file mode 100644 index 000000000..009e7e557 --- /dev/null +++ b/symfony/src/Ai/Watch/WatchSubject.php @@ -0,0 +1,53 @@ + 'une commande a quitté l’entrepôt', + self::PaiementRecu => 'un paiement a été encaissé', + self::StockReappro => 'un produit a été réapprovisionné', + self::FournisseurRepondu => 'un fournisseur a répondu à une demande', + self::ImportTermine => 'un import de catalogue s’est terminé', + }; + } + + /** + * @return list + */ + public static function values(): array + { + return array_map(static fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/symfony/src/Ai/Watch/WatchTool.php b/symfony/src/Ai/Watch/WatchTool.php new file mode 100644 index 000000000..acc5f1980 --- /dev/null +++ b/symfony/src/Ai/Watch/WatchTool.php @@ -0,0 +1,81 @@ + \sprintf('`%s` (%s)', $s->value, $s->describe()), + WatchSubject::cases(), + )); + } + + public static function definition(): ToolDefinition + { + return new ToolDefinition( + self::TOOL, + 'Se met en veille et attend qu’un événement extérieur survienne. À utiliser quand la ' + .'suite dépend de quelque chose qui n’a pas encore eu lieu — une livraison, un retour ' + .'de fournisseur, un seuil franchi. L’exécution reprendra à l’alerte, même des jours ' + .'plus tard.', + ToolEffect::Read, + [ + 'type' => 'object', + 'properties' => [ + 'sujet' => [ + 'type' => 'string', + 'enum' => WatchSubject::values(), + 'description' => 'L’événement que tu attends, à choisir dans la liste : ' + .self::catalogue() + .'. C’est lui, et pas ta phrase, qui réveillera la veille — un sujet ' + .'hors liste sera refusé.', + ], + 'observation' => [ + 'type' => 'string', + 'description' => 'Ce que tu guettes, dit en une phrase lisible par un humain.', + ], + 'intention' => [ + 'type' => 'string', + 'description' => 'Ce que tu feras quand l’alerte arrivera. Écris-le maintenant : ' + .'c’est ce qu’on te rendra au réveil, tu n’auras pas à t’en souvenir.', + ], + 'deadlineSeconds' => [ + 'type' => 'number', + 'description' => 'Au-delà de ce délai, la veille est abandonnée et tu reprends la main.', + ], + ], + 'required' => ['sujet', 'observation', 'intention'], + ], + ); + } +} diff --git a/symfony/src/Ai/Workflow/DurableAgentWorkflow.php b/symfony/src/Ai/Workflow/DurableAgentWorkflow.php new file mode 100644 index 000000000..ecc41a061 --- /dev/null +++ b/symfony/src/Ai/Workflow/DurableAgentWorkflow.php @@ -0,0 +1,400 @@ + */ + private array $inbox = []; + + private bool $closed = false; + + private AgentMode $mode = AgentMode::Standard; + + /** + * Ce que cet agent ne peut pas dépasser, quoi qu'il demande. `auto` pour un agent de premier + * rang — c'est-à-dire aucune borne — et le mode effectif du parent pour un délégué. + */ + private AgentMode $ceiling = AgentMode::Auto; + + private readonly ToolApprovalGate $gate; + + private readonly HumanQuestionDesk $desk; + + private readonly WatchDesk $watches; + + public function __construct( + private readonly WorkflowEnvironment $environment, + ) { + $this->gate = new ToolApprovalGate(); + $this->desk = new HumanQuestionDesk(); + $this->watches = new WatchDesk(); + } + + /** + * Une file, pas un champ : un second message posté pendant que l'agent travaille écraserait le + * premier. L'ordre de consommation est l'ordre du journal, donc stable au rejeu. + * + * @param array $payload + */ + #[AsSignalMethod('user_message')] + public function onUserMessage(array $payload): void + { + $text = trim((string) ($payload['text'] ?? '')); + if ('' !== $text) { + $this->inbox[] = $text; + } + } + + /** + * L'accord — ou le refus — d'un appel d'outil. L'exécution suspendue sur sa condition reprend ici. + * + * @param array $payload + */ + #[AsSignalMethod('tool_decision')] + public function onToolDecision(array $payload): void + { + $callId = (string) ($payload['callId'] ?? ''); + if ('' !== $callId) { + $this->gate->decide($callId, (bool) ($payload['approved'] ?? false)); + } + } + + /** + * La réponse à une question posée par l'agent. C'est l'autre sens de la conversation : ici + * l'humain ne pilote pas, il renseigne. + * + * @param array $payload + */ + #[AsSignalMethod('question_answered')] + public function onQuestionAnswered(array $payload): void + { + $callId = (string) ($payload['callId'] ?? ''); + if ('' !== $callId) { + $this->desk->answer($callId, \is_array($payload['answers'] ?? null) ? $payload['answers'] : []); + } + } + + /** + * L'alerte qui lève une veille. Elle vient du dehors — une supervision, un webhook, un autre + * agent — et c'est le journal, pas le modèle, qui rappellera à l'agent ce qu'il comptait faire. + * + * @param array $payload + */ + #[AsSignalMethod('alerte')] + public function onAlerte(array $payload): void + { + $callId = (string) ($payload['callId'] ?? ''); + if ('' !== $callId) { + $this->watches->raise($callId, (string) ($payload['observation'] ?? '')); + } + } + + /** + * Changer de mode en cours de conversation est journalisé, donc rejoué à l'identique. + * + * @param array $payload + */ + /** + * Le mode change, **sans jamais desserrer le plafond**. + * + * Le plafond vaut à l'entrée *et* en cours de route : un sous-agent qui accepterait + * `set_mode: auto` n'aurait pas de plafond du tout, et déléguer redeviendrait le chemin + * d'échappement de la garde. Un agent de premier rang a `auto` pour plafond — la borne ne lui + * coûte rien. + */ + #[AsSignalMethod('set_mode')] + public function onSetMode(array $payload): void + { + $demande = AgentMode::tryFrom((string) ($payload['mode'] ?? '')); + if (null === $demande || $demande->loosens($this->ceiling)) { + return; + } + + $this->mode = $demande; + } + + /** + * @param array $payload + */ + #[AsSignalMethod('close')] + public function onClose(array $payload): void + { + $this->closed = true; + } + + /** + * Réduire une conversation froide à ce qu'il faut en savoir. + * + * Un appel modèle, pas une boucle d'agent : il n'y a rien à outiller ici, et passer par + * `Runner` ne ferait qu'exposer la compaction aux gardes et aux appels d'outils. L'appel sort + * du journal comme les autres — donc rejoué, donc payé une fois. + * + * Un résumé vide n'est pas un résumé : le fil repart alors tel quel. C'est le seul choix qui + * garde l'affichage et le sac d'accord — la projection, elle aussi, retombe sur le fil brut + * quand le journal ne porte pas de résumé exploitable. + * + * @param list $thread + * + * @return list + */ + private function compact(string $model, array $thread): array + { + $result = $this->environment->await( + $this->environment + ->activityStub(ModelInvocationActivityInterface::class) + ->compactConversation($model, ['messages' => [ + ['role' => 'system', 'content' => self::COMPACTION_PROMPT], + // Sans étiquette : ce qui part au modèle est la conversation, pas la façon + // dont on la lui a présentée la fois d'avant. + ...TranscriptMessage::listToWire( + array_map(static fn (TranscriptMessage $m): TranscriptMessage => $m->stripped(), $thread), + ), + ]], []), + ); + + $digest = trim((string) (ChatCompletion::fromWire($result)?->text ?? '')); + + return '' === $digest ? $thread : [TranscriptMessage::compaction($digest)]; + } + + /** + * La charge arrive du journal, donc en tableaux : `$tools` est converti en + * {@see Toolset} dès l'entrée, et plus rien en dessous ne manipule de tableau associatif. + * + * @param array|null, effect?: string}> $tools + * @param float|null $idleTimeoutSeconds silence au bout duquel l'exécution se termine ; `null` = jamais + * @param int|null $rolloverAfterTurns tours au bout desquels le run passe la main à un run neuf ; `null` = jamais + * @param bool $compactHistory remplacer le fil repris par un résumé avant le premier tour + * @param list $history le fil repris d'une exécution précédente + * @param list $pending messages reçus mais pas encore traités, transmis par le run précédent + * + * @return string la dernière réponse de l'agent + */ + #[AsWorkflowMethod] + public function run( + array $tools = [], + string $model = 'mistral-small-latest', + string $mode = 'standard', + string $modeCeiling = 'auto', + string $systemPrompt = self::SYSTEM_PROMPT, + ?string $prompt = null, + int $maxTurns = 20, + int $maxToolCalls = 10, + ?float $humanTimeoutSeconds = null, + int $contextTokens = 24_000, + ?float $idleTimeoutSeconds = null, + ?int $rolloverAfterTurns = null, + bool $compactHistory = false, + array $history = [], + array $pending = [], + ?ToolGuardInterface $guard = null, + ): string { + // Le plafond d'abord : le mode demandé s'y plie, il ne le contourne pas. + $this->ceiling = AgentMode::tryFrom($modeCeiling) ?? AgentMode::Auto; + $this->mode = AgentMode::strictest($this->ceiling, AgentMode::tryFrom($mode) ?? AgentMode::Standard); + + // Ce que le run précédent n'a pas eu le temps de traiter passe devant : ces messages sont + // arrivés avant ceux que le nouveau run recevra. + foreach ($pending as $carried) { + $this->inbox[] = (string) $carried; + } + + // Une question posée au démarrage est le premier message de la file : rien à distinguer + // ensuite entre elle et celles qui arriveront par signal. + if (null !== $prompt && '' !== trim($prompt)) { + $this->inbox[] = trim($prompt); + } + + $agent = DurableAgentFactory::create( + $this->environment, + $model, + Toolset::fromWire($tools), + $maxToolCalls, + gate: $this->gate, + desk: $this->desk, + watches: $this->watches, + mode: fn(): AgentMode => $this->mode, + guard: $guard, + humanTimeout: Duration::fromWireValue($humanTimeoutSeconds), + budget: new ContextBudget($contextTokens), + ); + + // Le sac est reconstruit à chaque rejeu ; `$thread` en est la trace transportable — mêmes + // tours, forme du fil, sans les appels d'outils. + $thread = TranscriptMessage::listFromWire($history); + if ($compactHistory && [] !== $thread) { + $thread = $this->compact($model, $thread); + } + + $messages = new MessageBag(Message::forSystem($systemPrompt)); + foreach ($thread as $carried) { + $messages->add($carried->isUser() + ? Message::ofUser((string) $carried->content) + : Message::ofAssistant((string) $carried->content)); + } + + $answer = ''; + $turns = 0; + + while (!$this->closed && $turns < $maxTurns) { + try { + $this->environment->await( + fn(): bool => [] !== $this->inbox || $this->closed, + $idleTimeoutSeconds, + ); + } catch (DeadlineExceededException) { + // Un silence assez long vaut une fin. Rien n'est perdu : le fil est au journal, et + // la page propose de le reprendre dans une exécution neuve. + break; + } + + if ($this->closed) { + break; + } + + $text = (string) array_shift($this->inbox); + $messages->add(Message::ofUser($text)); + $thread[] = TranscriptMessage::user($text); + + // `Runner` ajoute lui-même les messages de la boucle d'outils au sac, mais pas la + // réponse finale : elle sort de la boucle sans y passer. + $result = $agent->call($messages)->getResult(); + // Le sac reçoit le résultat entier — `Message::toContent()` déplie un `MultiPartResult`, + // et le bloc de raisonnement repart ainsi au tour suivant. Le fil, lui, ne veut que le + // texte : `getContent()` d'un multi-parts rend un tableau, pas une chaîne. + $messages->add(Message::ofAssistant($result)); + $answer = $result instanceof MultiPartResult ? $result->asText() : (string) $result->getContent(); + $thread[] = TranscriptMessage::assistant($answer); + + ++$turns; + + // Le relais se prend ici, entre deux tours : rien n'est en vol, aucune garde n'attend + // de décision, et ce que la file a reçu pendant le tour part avec. + // + // ponytail: le seuil est un nombre de tours, pas la vraie grandeur. Ce qui coûte, c'est + // que chaque tour renvoie tout le sac au modèle — la taille de la charge du dernier + // `ai_model_invoke` est le déclencheur juste, le compte de tours n'en est que le proxy. + if (null !== $rolloverAfterTurns && $turns >= $rolloverAfterTurns && !$this->closed) { + // ponytail: un signal qui arrive pendant la tâche qui émet la commande peut se + // perdre — fenêtre irréductible, et la raison pour laquelle le relais est opt-in + // là où la clôture sur inactivité est le chemin par défaut. + $this->environment->continueAsNew(self::TYPE, [ + 'tools' => $tools, + 'model' => $model, + 'mode' => $this->mode->value, + 'systemPrompt' => $systemPrompt, + 'maxTurns' => $maxTurns, + 'maxToolCalls' => $maxToolCalls, + 'humanTimeoutSeconds' => $humanTimeoutSeconds, + 'contextTokens' => $contextTokens, + 'idleTimeoutSeconds' => $idleTimeoutSeconds, + 'rolloverAfterTurns' => $rolloverAfterTurns, + // Le relais transmet le fil tel quel : il a lieu au milieu d'une conversation + // vivante, où perdre le détail se paierait tout de suite. La compaction est + // pour les reprises froides. + 'compactHistory' => false, + 'history' => TranscriptMessage::listToWire($thread), + 'pending' => $this->inbox, + ]); + } + } + + return $answer; + } +} diff --git a/symfony/src/Command/EmitBusinessEventCommand.php b/symfony/src/Command/EmitBusinessEventCommand.php new file mode 100644 index 000000000..b3570c39d --- /dev/null +++ b/symfony/src/Command/EmitBusinessEventCommand.php @@ -0,0 +1,73 @@ +addArgument('sujet', InputArgument::REQUIRED, 'Un de : ' . implode(', ', WatchSubject::values())) + ->addArgument('details', InputArgument::IS_ARRAY, 'Précisions `clé=valeur`, rendues à l\'agent au réveil'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $subject = WatchSubject::tryFrom((string) $input->getArgument('sujet')); + if (null === $subject) { + $io->error('Sujet inconnu. Connus : ' . implode(', ', WatchSubject::values())); + + return Command::INVALID; + } + + $details = []; + foreach ((array) $input->getArgument('details') as $pair) { + [$key, $value] = array_pad(explode('=', (string) $pair, 2), 2, ''); + $details[$key] = $value; + } + + $reveilles = $this->router->dispatch(new BusinessEvent($subject, $details)); + + if ([] === $reveilles) { + $io->note('Personne ne guettait ' . $subject->value . '.'); + + return Command::SUCCESS; + } + + $io->success(\sprintf('%d veille(s) réveillée(s) : %s', \count($reveilles), implode(', ', $reveilles))); + + return Command::SUCCESS; + } +} diff --git a/symfony/src/Controller/AiChatController.php b/symfony/src/Controller/AiChatController.php new file mode 100644 index 000000000..4d21b3499 --- /dev/null +++ b/symfony/src/Controller/AiChatController.php @@ -0,0 +1,268 @@ + 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ]), + new ToolDefinition('save_note', 'Enregistre une note dans le dossier courant.', ToolEffect::Write, [ + 'type' => 'object', + 'properties' => ['text' => ['type' => 'string']], + 'required' => ['text'], + ]), + new ToolDefinition('send_email', 'Envoie un courriel. Effet externe : rien ne le rattrape.', ToolEffect::External, [ + 'type' => 'object', + 'properties' => ['to' => ['type' => 'string'], 'body' => ['type' => 'string']], + 'required' => ['to', 'body'], + ]), + ); + } + + public function __construct( + private readonly DurableSampleWorkflowRunner $workflowRunner, + private readonly ChatTranscript $transcript, + private readonly AgentSignals $signals, + ) { + } + + /** + * @param list $history le fil repris d'une exécution close + * @param bool $compactHistory remplacer ce fil par un résumé avant le premier tour + * @param int $contextTokens budget de fenêtre de l'agent + */ + private function startAgent(array $history = [], bool $compactHistory = false, int $contextTokens = self::DEFAULT_CONTEXT_TOKENS): string + { + $executionId = (string) Uuid::v4(); + $this->workflowRunner->dispatchWorkflowRun( + DurableAgentWorkflow::class, + [ + 'tools' => self::tools()->toWire(), + 'mode' => AgentMode::Standard->value, + 'humanTimeoutSeconds' => self::HUMAN_TIMEOUT_SECONDS, + 'contextTokens' => $contextTokens, + 'idleTimeoutSeconds' => self::IDLE_TIMEOUT_SECONDS, + 'rolloverAfterTurns' => self::ROLLOVER_AFTER_TURNS, + 'compactHistory' => $compactHistory, + 'history' => $history, + ], + $executionId, + ); + + return $executionId; + } + + /** + * `?contexte=300` ouvre une conversation à budget minuscule : la compaction se déclenche alors + * en deux ou trois messages au lieu de plusieurs centaines, et devient observable à la main. + */ + #[Route('/durable/chat', name: 'durable_chat_start', methods: ['GET'])] + public function start(Request $request): Response + { + return $this->redirectToRoute('durable_chat_show', ['executionId' => $this->startAgent( + contextTokens: max(200, min(200_000, $request->query->getInt('contexte', self::DEFAULT_CONTEXT_TOKENS))), + )]); + } + + /** + * Reprendre une conversation dont l'exécution est close — inactivité, `close`, ou tours épuisés. + * + * Une exécution close ne se rouvre pas : on en ouvre une neuve, et le fil qu'elle reprend sort + * de la projection du journal de l'ancienne. Rien n'est stocké entre les deux. + * + * Et il repart **compacté** : une conversation qu'on reprend après une heure de silence est + * froide, et la rejouer mot pour mot ferait payer au premier tour tout ce que le précédent + * avait déjà coûté. Le résumé est un appel modèle du run repris, donc journalisé, donc payé + * une fois même si la reprise redémarre. + */ + #[Route('/durable/chat/{executionId}/resume', name: 'durable_chat_resume', methods: ['POST'])] + public function resume(string $executionId): JsonResponse + { + $resumed = $this->startAgent($this->transcript->forExecution($executionId)->seed(), compactHistory: true); + + return new JsonResponse( + ['url' => $this->generateUrl('durable_chat_show', ['executionId' => $resumed])], + Response::HTTP_CREATED, + ); + } + + #[Route('/durable/chat/{executionId}', name: 'durable_chat_show', methods: ['GET'])] + public function show(string $executionId): Response + { + return $this->render('ai/chat.html.twig', ['executionId' => $executionId]); + } + + #[Route('/durable/chat/{executionId}/message', name: 'durable_chat_message', methods: ['POST'])] + public function message(string $executionId, Request $request): JsonResponse + { + $text = trim((string) ($request->toArray()['text'] ?? '')); + if ('' === $text) { + return new JsonResponse(['error' => 'Message vide.'], Response::HTTP_BAD_REQUEST); + } + + // Le workflow est suspendu sur sa condition ; le signal le réveille. Rien à attendre ici, + // la page relit la projection. + $this->signals->send($executionId, 'user_message', ['text' => $text]); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + /** + * L'accord ou le refus d'un appel d'outil : un signal, donc journalisé, donc rejoué. Le workflow + * peut avoir été suspendu là-dessus depuis des jours. + */ + #[Route('/durable/chat/{executionId}/decision', name: 'durable_chat_decision', methods: ['POST'])] + public function decision(string $executionId, Request $request): JsonResponse + { + $body = $request->toArray(); + $this->signals->send($executionId, 'tool_decision', [ + 'callId' => (string) ($body['callId'] ?? ''), + 'approved' => (bool) ($body['approved'] ?? false), + ]); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + /** + * La réponse à une question posée par l'agent. Rien à valider ici : l'humain renseigne, il + * n'autorise pas. + */ + #[Route('/durable/chat/{executionId}/answer', name: 'durable_chat_answer', methods: ['POST'])] + public function answer(string $executionId, Request $request): JsonResponse + { + $body = $request->toArray(); + $answers = \is_array($body['answers'] ?? null) ? $body['answers'] : []; + + $this->signals->send($executionId, 'question_answered', [ + 'callId' => (string) ($body['callId'] ?? ''), + 'answers' => array_values(array_map(strval(...), $answers)), + ]); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + /** + * Lever une alerte : c'est le rôle qu'une supervision, un webhook ou un autre agent tiendrait + * en production. La page l'imite pour que la veille soit démontrable. + */ + #[Route('/durable/chat/{executionId}/alert', name: 'durable_chat_alert', methods: ['POST'])] + public function alert(string $executionId, Request $request): JsonResponse + { + $body = $request->toArray(); + + $this->signals->send($executionId, 'alerte', [ + 'callId' => (string) ($body['callId'] ?? ''), + 'observation' => (string) ($body['observation'] ?? ''), + ]); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + #[Route('/durable/chat/{executionId}/mode', name: 'durable_chat_mode', methods: ['POST'])] + public function mode(string $executionId, Request $request): JsonResponse + { + $mode = AgentMode::tryFrom((string) ($request->toArray()['mode'] ?? '')); + if (null === $mode) { + return new JsonResponse(['error' => 'Mode inconnu.'], Response::HTTP_BAD_REQUEST); + } + + $this->signals->send($executionId, 'set_mode', ['mode' => $mode->value]); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + #[Route('/durable/chat/{executionId}/close', name: 'durable_chat_close', methods: ['POST'])] + public function close(string $executionId): JsonResponse + { + $this->signals->send($executionId, 'close', []); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } + + /** + * Le fil, tel que le journal le raconte, avec un ETag. + * + * Le sondage demande toutes les secondes et la réponse est presque toujours la même : l'ETag + * la rend gratuite à sérialiser et à transporter. **Il ne rend pas la lecture gratuite** — + * {@see ChatTranscript::forExecution()} rejoue le flux d'événements à chaque appel, et ça, seul + * un vrai canal poussé l'éviterait. Ce qui coûte ici, c'est la lecture, pas le JSON. + */ + #[Route('/durable/chat/{executionId}/transcript', name: 'durable_chat_transcript', methods: ['GET'])] + public function transcriptJson(string $executionId, Request $request): JsonResponse + { + $response = new JsonResponse($this->transcript->forExecution($executionId)); + $response->setEtag(hash('xxh128', (string) $response->getContent())); + $response->setPrivate(); + $response->isNotModified($request); + + return $response; + } +} diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php index cb849fe0a..169fbf92b 100644 --- a/symfony/src/Controller/DashboardController.php +++ b/symfony/src/Controller/DashboardController.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +#[Route(host: '%app.host.samples%')] final class DashboardController extends AbstractController { private const DASHBOARD_PAGE_SIZE = 20; diff --git a/symfony/src/Controller/DocumentationController.php b/symfony/src/Controller/DocumentationController.php index e4529bdd4..233a60212 100644 --- a/symfony/src/Controller/DocumentationController.php +++ b/symfony/src/Controller/DocumentationController.php @@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +#[Route(host: '%app.host.samples%')] final class DocumentationController extends AbstractController { #[Route('/documentation', name: 'durable_documentation', methods: ['GET'])] diff --git a/symfony/src/Controller/SamplesWorkflowController.php b/symfony/src/Controller/SamplesWorkflowController.php index c937b79af..638f2e5ee 100644 --- a/symfony/src/Controller/SamplesWorkflowController.php +++ b/symfony/src/Controller/SamplesWorkflowController.php @@ -14,6 +14,7 @@ /** * UI listing ported temporalio/samples-php scenarios and starting a run via {@see DurableSampleWorkflowRunner}. */ +#[Route(host: '%app.host.samples%')] final class SamplesWorkflowController extends AbstractController { public function __construct( diff --git a/symfony/src/Samples/SampleWorkflowCatalog.php b/symfony/src/Samples/SampleWorkflowCatalog.php index 98073b9ff..d53ab0bf9 100644 --- a/symfony/src/Samples/SampleWorkflowCatalog.php +++ b/symfony/src/Samples/SampleWorkflowCatalog.php @@ -4,6 +4,7 @@ namespace App\Samples; +use App\Ai\Workflow\DurableAgentWorkflow; use App\Samples\Workflow\ActivityRetry\ActivityRetryGreetingWorkflow; use App\Samples\Workflow\BookingSaga\BookingSagaLightWorkflow; use App\Samples\Workflow\CancellationScope\CancellationScopeRaceWorkflow; @@ -48,6 +49,32 @@ final class SampleWorkflowCatalog public static function scenarios(): array { return [ + [ + 'id' => 'durable_agent', + 'sourceFolder' => 'Ai', + 'label' => 'Agent durable (Symfony AI)', + 'workflowType' => self::workflowAlias(DurableAgentWorkflow::class), + 'description' => 'La boucle d’appel d’outils de Symfony AI exécutée en code workflow : chaque appel modèle et chaque outil est une activité, donc journalisé et jamais rejoué. Ici un prompt et un seul tour ; sans prompt, le même workflow est un chat piloté par signaux.', + 'defaultPayload' => [ + // Même workflow que le chat : un prompt de départ et un seul tour, donc + // l'exécution se termine et la page peut afficher un résultat. + 'prompt' => 'Quelle météo à Paris ?', + 'maxTurns' => 1, + 'mode' => 'auto', + 'model' => 'mistral-small-latest', + 'tools' => [ + 'weather' => [ + 'description' => 'Météo courante d’une ville.', + 'effect' => 'read', + 'parameters' => [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + ], + ], + ], + ], [ 'id' => 'simple_activity', 'sourceFolder' => 'SimpleActivity', diff --git a/symfony/templates/ai/chat.html.twig b/symfony/templates/ai/chat.html.twig new file mode 100644 index 000000000..aa2876593 --- /dev/null +++ b/symfony/templates/ai/chat.html.twig @@ -0,0 +1,606 @@ +{% extends 'base.html.twig' %} + +{% block title %}Chat durable — Durable{% endblock %} + +{% block body %} +
+ + +
+

Chat durable

+

+ Une conversation = une exécution de workflow. Chaque message part en + signal ; entre deux messages le workflow n’attend pas dans un processus, + il est suspendu. Le fil affiché est une projection du journal — rien n’est stocké + en session. +

+

+ Chaque appel d’outil passe par une garde. Le mode décide de ce qui passe + sans demander ; le reste suspend le workflow jusqu’à ta décision — signal + tool_decision. Essaie « envoie un mail » en mode auto puis en + standard. +

+
+ +
+
+ executionId + {{ executionId }} + +
+ + +
+ + … + +
+ + +
+ + +
+
+ + + + +{% endblock %} diff --git a/symfony/templates/samples/index.html.twig b/symfony/templates/samples/index.html.twig index 73f287f5c..e1a33171f 100644 --- a/symfony/templates/samples/index.html.twig +++ b/symfony/templates/samples/index.html.twig @@ -19,6 +19,7 @@ diff --git a/symfony/tests/Integration/AiAgentLifecycleTest.php b/symfony/tests/Integration/AiAgentLifecycleTest.php new file mode 100644 index 000000000..8cb7b5013 --- /dev/null +++ b/symfony/tests/Integration/AiAgentLifecycleTest.php @@ -0,0 +1,226 @@ +dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'idleTimeoutSeconds' => 0.05, + ]); + + $this->drainMessengerUntilSettled($executionId); + + $this->assertWorkflowResultEquals($executionId, ''); + } + + /** + * Au bout de N tours, le run passe la main — et ce qu'il transmet est le fil parlé, dans la + * forme que la projection sait déjà relire. + */ + public function testTheRunHandsOverItsThreadAfterTheRolloverThreshold(): void + { + $executionId = $this->dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'prompt' => 'Salut', + 'maxTurns' => 5, + 'rolloverAfterTurns' => 1, + ]); + + $this->drainUntilHandover($executionId); + + $continued = null; + foreach ($this->getEventStoreService()->readStream($executionId) as $event) { + if ($event instanceof WorkflowContinuedAsNew) { + $continued = $event; + } + } + + self::assertNotNull($continued, 'le run devait passer la main après son premier tour'); + self::assertSame(DurableAgentWorkflow::TYPE, $continued->nextWorkflowType()); + + $history = $continued->nextPayload()['history'] ?? []; + self::assertCount(2, $history); + self::assertSame(['role' => 'user', 'content' => 'Salut'], $history[0]); + self::assertSame('assistant', $history[1]['role']); + self::assertNotSame('', $history[1]['content']); + + // La charge transmise n'a de valeur que si le run suivant en fait vraiment un sac : le + // modèle doit voir le fil repris *et* le nouveau message, pas repartir de zéro. + $successor = $this->dispatchWorkflow( + DurableAgentWorkflow::class, + $continued->nextPayload() + ['prompt' => 'Salut encore'], + ); + $this->drainUntilHandover($successor); + + $resumed = (new ChatTranscript($this->getEventStoreService(), $this->getWorkflowMetadataStore())) + ->forExecution($successor); + + self::assertGreaterThanOrEqual(4, \count($resumed->messages), 'le fil repris doit précéder le nouveau tour'); + self::assertSame('Salut', $resumed->messages[0]->content); + self::assertSame('Salut encore', $resumed->messages[2]->content); + } + + /** + * Une reprise froide ne rejoue pas la conversation : elle la résume. Le modèle du tour suivant + * ne doit voir qu'un message repris, pas les quatre d'avant — c'est tout l'intérêt. + */ + public function testAResumedRunStartsFromASummary(): void + { + $executionId = $this->dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'compactHistory' => true, + 'history' => [ + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => '18 °C'], + ['role' => 'user', 'content' => 'Et à Paris ?'], + ['role' => 'assistant', 'content' => '14 °C'], + ], + 'prompt' => 'Merci', + 'maxTurns' => 1, + ]); + + $this->drainMessengerUntilSettled($executionId); + + $resumed = (new ChatTranscript($this->getEventStoreService(), $this->getWorkflowMetadataStore())) + ->forExecution($executionId); + + // Le résumé, le nouveau message, la réponse. Pas les quatre tours d'avant. + self::assertCount(3, $resumed->messages); + self::assertStringStartsWith('Résumé de notre conversation précédente :', (string) $resumed->messages[0]->content); + self::assertStringContainsString('Météo à Lyon', (string) $resumed->messages[0]->content); + self::assertSame('Merci', $resumed->messages[1]->content); + } + + /** + * Une conversation qu'on reprend, on la reprend deux fois — et le résumé de la première part + * alors au modèle comme conversation à résumer. Il doit y arriver **sans son étiquette** : + * celle-ci est pour l'humain, et la renvoyer ferait résumer un résumé étiqueté. + */ + public function testTheSecondCompactionIsSentWithoutTheSummaryLabel(): void + { + $first = $this->dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'compactHistory' => true, + 'history' => [ + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => '18 °C'], + ], + 'idleTimeoutSeconds' => 0.05, + ]); + $this->drainMessengerUntilSettled($first); + + $transcript = new ChatTranscript($this->getEventStoreService(), $this->getWorkflowMetadataStore()); + $seed = $transcript->forExecution($first)->seed(); + self::assertStringStartsWith('Résumé de notre conversation précédente :', $seed[0]['content']); + + $second = $this->dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'compactHistory' => true, + 'history' => $seed, + 'idleTimeoutSeconds' => 0.05, + ]); + $this->drainMessengerUntilSettled($second); + + foreach ($this->compactionPayload($second) as $message) { + self::assertStringNotContainsString( + 'Résumé de notre conversation précédente', + (string) ($message['content'] ?? ''), + 'l’étiquette du résumé ne doit pas repartir au modèle', + ); + } + } + + /** + * Les messages tels que la compaction les a envoyés au modèle. + * + * @return list> + */ + private function compactionPayload(string $executionId): array + { + foreach ($this->getEventStoreService()->readStream($executionId) as $event) { + if ($event instanceof ActivityScheduled && 'ai_model_compact' === $event->activityName()) { + $payload = $event->payload(); + while (!\array_key_exists('messages', $payload) && \is_array($payload['payload'] ?? null)) { + $payload = $payload['payload']; + } + + return $payload['messages'] ?? []; + } + } + + self::fail('aucune compaction au journal'); + } + + /** + * La compaction porte un nom d'activité à part, et ce n'est pas cosmétique : sous + * `ai_model_invoke`, la projection prendrait sa charge — la conversation qu'on remplace — pour + * le tour en cours, et une reprise restée silencieuse réafficherait tout l'ancien fil. + */ + public function testACompactedRunGoneIdleShowsTheSummaryNotTheOldThread(): void + { + $executionId = $this->dispatchWorkflow(DurableAgentWorkflow::class, [ + 'tools' => [], + 'compactHistory' => true, + 'history' => [ + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => '18 °C'], + ], + 'idleTimeoutSeconds' => 0.05, + ]); + + $this->drainMessengerUntilSettled($executionId); + + $resumed = (new ChatTranscript($this->getEventStoreService(), $this->getWorkflowMetadataStore())) + ->forExecution($executionId); + + self::assertCount(1, $resumed->messages); + self::assertStringStartsWith('Résumé de notre conversation précédente :', (string) $resumed->messages[0]->content); + } + + /** + * Un run qui passe la main ne « se termine » pas : il n'a pas de résultat, et sa métadonnée + * disparaît au profit de celle du run suivant. Le drain du trait attend un résultat, celui-ci + * attend la relève. + */ + private function drainUntilHandover(string $executionId): void + { + DurableMessengerDrain::drainUntilCompleteOrSignalWait( + $this->getEventStoreService(), + $this->getWorkflowMetadataStore(), + $this->getMessageBus(), + $this->getMessengerReceiverLocator(), + $executionId, + ); + } +} diff --git a/symfony/tests/Unit/Ai/AskUserQuestionTest.php b/symfony/tests/Unit/Ai/AskUserQuestionTest.php new file mode 100644 index 000000000..ecade341b --- /dev/null +++ b/symfony/tests/Unit/Ai/AskUserQuestionTest.php @@ -0,0 +1,218 @@ + static function (array $payload) use (&$offered): array { + foreach ($payload['options']['tools'] ?? [] as $tool) { + $offered[] = $tool['function']['name'] ?? '?'; + } + + return self::text('Bonjour.'); + }, + ]); + + $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'question-0'); + + self::assertContains(AskUserQuestion::TOOL, $offered); + } + + public function testTheAnswerComesBackToTheModelAsTheToolResult(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + + // La réponse est déjà au journal : le workflow l'applique en atteignant sa condition. + // C'est bien un signal — le même que celui qu'enverrait la page, à la même place. + $this->answerUpFront($environment, 'question-1', ['Par lot']); + + self::assertSame('Compris : Par lot', $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'question-1')); + } + + public function testSeveralAnswersTravelTogetherWhenTheQuestionAllowsIt(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + $this->answerUpFront($environment, 'question-2', ['Par lot', 'En arrière-plan']); + + self::assertSame('Compris : Par lot ; En arrière-plan', $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'question-2')); + } + + /** + * Sans réponse, l'agent ne reste pas planté : l'échéance lui rend une phrase qui le lui dit, + * et il poursuit. L'horloge virtuelle du runner avance d'échéance en échéance. + */ + public function testAQuestionNobodyAnswersExpiresAndTheAgentIsToldSo(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + + $answer = $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(5.0), 'question-3'); + + self::assertStringContainsString('Aucune réponse', $answer); + } + + /** + * La garde reste devant, pas à côté : elle décide de **tous** les appels, y compris de celui-ci. + * + * En mode `standard`, où toute écriture demande une validation, poser une question passe sans + * rien demander — elle est classée `read`. Si la garde l'avait retenue, ce test resterait + * suspendu faute de signal `tool_decision`, et c'est exactement ce qu'il vérifie. + */ + public function testAskingIsNotSomethingOneHasToApproveFirst(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + $this->answerUpFront($environment, 'question-5', ['Par lot']); + + $input = ['mode' => 'standard'] + $this->input(); + + self::assertSame( + 'Compris : Par lot', + $environment->runWorkflowClass(DurableAgentWorkflow::class, $input, 'question-5'), + ); + } + + /** + * Et l'inverse tient aussi : une politique qui interdit de poser des questions les interdit + * pour de bon. C'est la preuve que la garde est bien en amont — sinon le guichet suspendrait + * l'exécution avant qu'elle n'ait son mot à dire. + */ + public function testAPolicyCanForbidAskingAltogether(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + + // Aucun signal n'est déposé : si la question atteignait le guichet, l'exécution resterait + // suspendue au lieu de rendre une réponse. + $input = ['guard' => new ModeToolGuard(denied: [AskUserQuestion::TOOL])] + $this->input(); + $answer = $environment->runWorkflowClass(DurableAgentWorkflow::class, $input, 'question-6'); + + self::assertStringContainsString('interdit par la politique', $answer); + } + + /** + * Aucune activité n'est planifiée pour une question : il n'y a rien à exécuter. + */ + public function testAQuestionNeverReachesAnActivity(): void + { + $toolCalls = 0; + $handlers = $this->scriptedModel(); + $handlers['ai_tool_call'] = static function (array $payload) use (&$toolCalls): string { + ++$toolCalls; + + return 'exécuté'; + }; + + $environment = WorkflowTestEnvironment::inMemory($handlers); + $this->answerUpFront($environment, 'question-4', ['Par lot']); + + $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'question-4'); + + self::assertSame(0, $toolCalls); + } + + /** + * L'ordre compte : le curseur des messages ne lit que ce qui suit `ExecutionStarted`. + * + * @param list $answers + */ + private function answerUpFront(WorkflowTestEnvironment $environment, string $executionId, array $answers): void + { + $environment->getEventStore()->append(new ExecutionStarted($executionId, [])); + $environment->getEventStore()->append(new WorkflowSignalReceived( + $executionId, + 'question_answered', + ['callId' => self::CALL, 'answers' => $answers], + )); + } + + /** + * La forme de production : c'est le chargeur de définition qui enregistre les + * `#[AsSignalMethod]`. Instancier la classe dans une closure les laisserait sur le carreau, et + * aucun signal n'atteindrait jamais sa condition. + * + * @return array + */ + private function input(?float $timeout = null): array + { + return [ + 'prompt' => 'Importe le catalogue', + 'maxTurns' => 1, + 'mode' => 'auto', + 'humanTimeoutSeconds' => $timeout, + ]; + } + + /** + * Premier tour : le modèle pose la question. Second tour : il relit ce qu'on lui a répondu. + * + * @return array + */ + private function scriptedModel(): array + { + $round = 0; + + return [ + 'ai_model_invoke' => static function (array $payload) use (&$round): array { + if (0 === $round++) { + return self::toolCall(self::CALL, AskUserQuestion::TOOL, [ + 'question' => 'Comment veux-tu importer ?', + 'header' => 'Import', + 'options' => [ + ['label' => 'Par lot', 'description' => 'Tout d’un coup'], + ['label' => 'En arrière-plan', 'description' => 'Au fil de l’eau'], + ], + 'multiSelect' => true, + ]); + } + + $last = end($payload['payload']['messages']); + + return self::text('Compris : '.$last['content']); + }, + ]; + } + + /** + * @param array $arguments + * + * @return array + */ + private static function toolCall(string $id, string $name, array $arguments): array + { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => $id, + 'type' => 'function', + 'function' => ['name' => $name, 'arguments' => json_encode($arguments, \JSON_UNESCAPED_UNICODE)], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + /** + * @return array + */ + private static function text(string $text): array + { + return ['choices' => [['message' => ['content' => $text], 'finish_reason' => 'stop']]]; + } +} diff --git a/symfony/tests/Unit/Ai/ChatCompletionTest.php b/symfony/tests/Unit/Ai/ChatCompletionTest.php new file mode 100644 index 000000000..0b15ec98e --- /dev/null +++ b/symfony/tests/Unit/Ai/ChatCompletionTest.php @@ -0,0 +1,86 @@ + [['message' => ['content' => 'Bonjour.'], 'finish_reason' => 'stop']]], + ChatCompletion::ofText('Bonjour.')->toWire(), + ); + } + + /** + * La forme de Mistral : des morceaux `thinking`/`text`, et non un `reasoning_content` à côté — + * que Mistral refuse par un 422. + */ + public function testReasoningIsWrittenAsThinkingChunks(): void + { + self::assertSame( + ['choices' => [['message' => ['content' => [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => 'Je pèse.']]], + ['type' => 'text', 'text' => 'Bonjour.'], + ]], 'finish_reason' => 'stop']]], + ChatCompletion::ofText('Bonjour.', 'Je pèse.')->toWire(), + ); + } + + public function testAToolCallCarriesItsArgumentsAsAJsonString(): void + { + self::assertSame( + ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => 'call_3', + 'type' => 'function', + 'function' => ['name' => 'weather', 'arguments' => '{"city":"Paris"}'], + ]]], 'finish_reason' => 'tool_calls']]], + ChatCompletion::ofToolCall(new ToolCallRef('call_3', 'weather', ['city' => 'Paris']))->toWire(), + ); + } + + public function testWhatIsWrittenIsWhatIsRead(): void + { + $relu = ChatCompletion::fromWire( + ChatCompletion::ofToolCall(new ToolCallRef('call_3', 'weather', ['city' => 'Paris']))->toWire(), + ); + + self::assertNotNull($relu); + self::assertNull($relu->text); + self::assertEquals([new ToolCallRef('call_3', 'weather', ['city' => 'Paris'])], $relu->toolCalls); + + $raisonne = ChatCompletion::fromWire(ChatCompletion::ofText('Bonjour.', 'Je pèse.')->toWire()); + self::assertSame('Bonjour.', $raisonne?->text); + self::assertSame('Je pèse.', $raisonne?->reasoning); + } + + /** + * Un journal qui ne porte pas encore de réponse rend `null`, et ce n'est pas un défaut : c'est + * ce qui distingue un tour en cours d'un tour fini ({@see \App\Ai\Chat\ChatTranscript}). + */ + public function testAnAbsentAnswerIsNull(): void + { + self::assertNull(ChatCompletion::fromWire(null)); + self::assertNull(ChatCompletion::fromWire('pas un tableau')); + self::assertNull(ChatCompletion::fromWire(['choices' => []])); + } +} diff --git a/symfony/tests/Unit/Ai/ChatTranscriptResumeTest.php b/symfony/tests/Unit/Ai/ChatTranscriptResumeTest.php new file mode 100644 index 000000000..337218b9c --- /dev/null +++ b/symfony/tests/Unit/Ai/ChatTranscriptResumeTest.php @@ -0,0 +1,158 @@ + */ + private const HISTORY = [ + ['role' => 'user', 'content' => 'Bonjour'], + ['role' => 'assistant', 'content' => 'Bonjour !'], + ]; + + private InMemoryEventStore $eventStore; + private InMemoryWorkflowMetadataStore $metadataStore; + private ChatTranscript $transcript; + + protected function setUp(): void + { + $this->eventStore = new InMemoryEventStore(); + $this->metadataStore = new InMemoryWorkflowMetadataStore(); + $this->transcript = new ChatTranscript($this->eventStore, $this->metadataStore); + } + + public function testTheCarriedThreadShowsBeforeAnyModelCall(): void + { + $this->metadataStore->save(self::EXECUTION, 'Ai_DurableAgent', ['history' => self::HISTORY]); + + $transcript = $this->transcript->forExecution(self::EXECUTION); + + self::assertSame( + ['Bonjour', 'Bonjour !'], + array_map(static fn (TranscriptMessage $message): ?string => $message->content, $transcript->messages), + ); + self::assertFalse($transcript->working, 'un fil repris n’est pas un tour en cours'); + } + + /** + * Sans compter le fil repris du côté des messages reçus, un run repris paraît au repos pendant + * qu'il travaille : le premier signal de ce run seul ne fait pas le poids face au sac entier. + */ + public function testASignalOnAResumedRunStillCountsAsWorking(): void + { + $this->metadataStore->save(self::EXECUTION, 'Ai_DurableAgent', ['history' => self::HISTORY]); + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Et Lyon ?'])); + + self::assertTrue($this->transcript->forExecution(self::EXECUTION)->working); + } + + /** + * Après un continue-as-new, le store de métadonnées porte encore la charge du **premier** run. + * Seul l'événement du run courant dit ce que ce run a repris. + */ + public function testTheRunEventWinsOverTheStaleMetadataPayload(): void + { + $this->metadataStore->save(self::EXECUTION, 'Ai_DurableAgent', ['history' => []]); + $this->eventStore->append(new ExecutionStarted(self::EXECUTION, ['history' => self::HISTORY])); + + $transcript = $this->transcript->forExecution(self::EXECUTION); + + self::assertCount(2, $transcript->messages); + self::assertSame('Bonjour !', $transcript->messages[1]->content); + } + + /** + * Le journal du run courant l'emporte dès qu'il porte le fil : la charge de démarrage n'est + * qu'un repli. + */ + public function testAModelCallSupersedesTheCarriedThread(): void + { + $this->metadataStore->save(self::EXECUTION, 'Ai_DurableAgent', ['history' => self::HISTORY]); + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Et Lyon ?'])); + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [ + ['role' => 'user', 'content' => 'Bonjour'], + ['role' => 'assistant', 'content' => 'Bonjour !'], + ['role' => 'user', 'content' => 'Et Lyon ?'], + ]], + ])); + + self::assertCount(3, $this->transcript->forExecution(self::EXECUTION)->messages); + } + + /** + * Ce qui repart 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. + */ + public function testTheSeedDropsToolNoise(): void + { + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [ + ['role' => 'system', 'content' => 'Tu es concis.'], + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => null, 'tool_calls' => [ + ['id' => 'c1', 'function' => ['name' => 'weather', 'arguments' => '{"city":"Lyon"}']], + ]], + ['role' => 'tool', 'content' => '18 °C'], + ['role' => 'assistant', 'content' => 'Il fait 18 °C.'], + ]], + ])); + + self::assertSame([ + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => 'Il fait 18 °C.'], + ], $this->transcript->forExecution(self::EXECUTION)->seed()); + } + /** + * Un résumé peut arriver en morceaux `thinking`/`text` — c'est la forme d'un modèle qui + * raisonne, et la compaction passe par la même porte que le reste. + * + * La projection lisait `content` brut et n'en gardait que ce qui était **déjà une chaîne** : + * un résumé en morceaux était ignoré, et le fil repris s'affichait en entier alors que le + * modèle, lui, ne verrait que le résumé. {@see \App\Ai\Platform\ChatCompletion} fait le même + * découpage des deux côtés. + */ + public function testAChunkedDigestIsReadLikeAnyOtherAnswer(): void + { + $this->metadataStore->save(self::EXECUTION, 'Ai_DurableAgent', ['history' => self::HISTORY]); + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'c1', 'ai_model_compact', [])); + $this->eventStore->append(new ActivityCompleted(self::EXECUTION, 'c1', [ + 'choices' => [['message' => ['content' => [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => 'Je pèse ce qui compte.']]], + ['type' => 'text', 'text' => 'On s\'est salués.'], + ]]]], + ])); + + $transcript = $this->transcript->forExecution(self::EXECUTION); + + self::assertSame( + ['Résumé de notre conversation précédente : On s\'est salués.'], + array_map(static fn (TranscriptMessage $message): ?string => $message->content, $transcript->messages), + ); + } +} diff --git a/symfony/tests/Unit/Ai/ChatTranscriptStatusTest.php b/symfony/tests/Unit/Ai/ChatTranscriptStatusTest.php new file mode 100644 index 000000000..b8cbe0534 --- /dev/null +++ b/symfony/tests/Unit/Ai/ChatTranscriptStatusTest.php @@ -0,0 +1,115 @@ +eventStore = new InMemoryEventStore(); + $this->transcript = new ChatTranscript($this->eventStore, new InMemoryWorkflowMetadataStore()); + } + + public function testAFreshExecutionIsIdleNotWorking(): void + { + self::assertFalse($this->transcript->forExecution(self::EXECUTION)->working); + } + + public function testASignalledMessageTheModelHasNotSeenYetCountsAsWorking(): void + { + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Salut'])); + + self::assertTrue($this->transcript->forExecution(self::EXECUTION)->working); + } + + public function testAnAnsweredTurnIsIdleAgain(): void + { + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Salut'])); + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [['role' => 'user', 'content' => 'Salut']]], + ])); + $this->eventStore->append(new ActivityCompleted(self::EXECUTION, 'a1', [ + 'choices' => [['message' => ['content' => 'Bonjour.']]], + ])); + + $transcript = $this->transcript->forExecution(self::EXECUTION); + + self::assertFalse($transcript->working); + self::assertSame('Bonjour.', $transcript->messages[1]->content); + } + + public function testAScheduledModelCallWithoutItsResultIsWorking(): void + { + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Salut'])); + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [['role' => 'user', 'content' => 'Salut']]], + ])); + + self::assertTrue($this->transcript->forExecution(self::EXECUTION)->working); + } + + /** + * Le raisonnement se lit à deux endroits, et il faut les deux : celui d'un tour passé est + * remis par le normaliseur dans la charge du tour suivant, celui du dernier tour n'a pas de + * tour suivant et ne vit que dans le résultat. + * + * La forme est celle du pont Mistral — des morceaux `thinking` et `text` dans `content` — et + * non le `reasoning_content` du contrat générique, que Mistral refuse par un 422. + */ + public function testTheThreadCarriesTheReasoningOfPastAndLastTurns(): void + { + $this->eventStore->append(new WorkflowSignalReceived(self::EXECUTION, 'user_message', ['text' => 'Météo à Lyon ?'])); + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [ + ['role' => 'user', 'content' => 'Météo à Lyon ?'], + ['role' => 'assistant', 'content' => [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => 'Une ville est nommée, je lis la météo.']]], + ]], + ['role' => 'tool', 'content' => 'Lyon: 25°C'], + ]], + ])); + $this->eventStore->append(new ActivityCompleted(self::EXECUTION, 'a1', [ + 'choices' => [['message' => ['content' => [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => 'Le relevé est là, je réponds.']]], + ['type' => 'text', 'text' => 'Lyon 25°C.'], + ]]]], + ])); + + $messages = $this->transcript->forExecution(self::EXECUTION)->messages; + + self::assertSame('Une ville est nommée, je lis la météo.', $messages[1]->reasoning, 'Le raisonnement d\'un tour passé est perdu.'); + self::assertNull($messages[0]->reasoning, 'Un message de l\'humain n\'a pas de raisonnement.'); + self::assertSame('Lyon 25°C.', $messages[3]->content); + self::assertSame('Le relevé est là, je réponds.', $messages[3]->reasoning, 'Le raisonnement du dernier tour est perdu.'); + } + + public function testAMessageWithoutReasoningCarriesNullNotAnEmptyString(): void + { + $this->eventStore->append(new ActivityScheduled(self::EXECUTION, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [['role' => 'assistant', 'content' => 'Bonjour.', 'reasoning_content' => ' ']]], + ])); + + self::assertNull($this->transcript->forExecution(self::EXECUTION)->messages[0]->reasoning); + } +} diff --git a/symfony/tests/Unit/Ai/ContextBudgetTest.php b/symfony/tests/Unit/Ai/ContextBudgetTest.php new file mode 100644 index 000000000..50ddb2dd5 --- /dev/null +++ b/symfony/tests/Unit/Ai/ContextBudgetTest.php @@ -0,0 +1,132 @@ + 'system', 'content' => 'Sois concis.'], + ['role' => 'user', 'content' => 'Bonjour'], + ]; + + self::assertSame($messages, (new ContextBudget(10_000))->fit($messages)); + } + + public function testTheOldestTurnsGoFirstAndTheSystemMessageStays(): void + { + $fitted = (new ContextBudget(120))->fit($this->longConversation(20)); + + self::assertSame('system', $fitted[0]['role']); + self::assertSame('Sois concis.', $fitted[0]['content'], 'Le message système a été emporté.'); + self::assertStringContainsString('retirés du contexte', $fitted[1]['content']); + self::assertSame('Question 20', end($fitted)['content'], 'Le dernier tour doit toujours rester.'); + self::assertLessThan(\count($this->longConversation(20)), \count($fitted)); + } + + /** + * Le piège de la compaction : un `assistant` qui demande des outils et les `tool` qui lui + * répondent forment un bloc. Couper au milieu laisse un résultat orphelin, que les + * fournisseurs refusent. + */ + public function testAToolResultIsNeverLeftWithoutItsCall(): void + { + $messages = [['role' => 'system', 'content' => 'S']]; + for ($turn = 0; $turn < 12; ++$turn) { + $messages[] = ['role' => 'user', 'content' => \sprintf('Demande %d %s', $turn, str_repeat('x', 40))]; + $messages[] = ['role' => 'assistant', 'content' => null, 'tool_calls' => [['id' => 'c'.$turn]]]; + $messages[] = ['role' => 'tool', 'content' => 'résultat', 'tool_call_id' => 'c'.$turn]; + } + + $fitted = (new ContextBudget(200))->fit($messages); + + $ouverts = []; + foreach ($fitted as $message) { + foreach ($message['tool_calls'] ?? [] as $call) { + $ouverts[$call['id']] = true; + } + if ('tool' === $message['role']) { + self::assertArrayHasKey($message['tool_call_id'], $ouverts, 'Un résultat d’outil a perdu son appel.'); + } + } + } + + public function testHalvingTightensTheCeiling(): void + { + $budget = new ContextBudget(1_000); + + self::assertSame(500, $budget->halved()->maxTokens); + self::assertLessThan($budget->ceiling(), $budget->halved()->ceiling()); + } + + /** + * Rejouée, la compaction doit rendre exactement le même payload — sinon l'appel modèle + * journalisé et celui du rejeu divergent, et la garde de divergence (DUR042) tire. + */ + public function testCompactionIsPure(): void + { + $budget = new ContextBudget(120); + $messages = $this->longConversation(20); + + self::assertSame($budget->fit($messages), $budget->fit($messages)); + } + + public function testAProviderOverflowIsRecognisedByCodeOrByMessage(): void + { + self::assertTrue(ContextOverflow::detected(['error' => ['code' => 'context_length_exceeded']])); + self::assertTrue(ContextOverflow::detected(['message' => 'This model has a maximum context length of 32k'])); + self::assertFalse(ContextOverflow::detected(['error' => ['code' => 'rate_limit_exceeded']])); + self::assertFalse(ContextOverflow::detected(['choices' => [['message' => ['content' => 'ok']]]])); + } + + /** + * Le plafond de la compaction, et il est assumé : **un tour à lui seul plus gros que la + * fenêtre ne peut pas être compacté**. Abandonner le message auquel le modèle doit répondre + * n'aurait pas de sens ; on le laisse partir, le fournisseur le refuse, et c'est le chemin + * réactif ({@see ContextOverflow}) qui reprend la main. + */ + public function testASingleTurnBiggerThanTheWindowIsLeftAlone(): void + { + $enorme = [ + ['role' => 'system', 'content' => 'Sois concis.'], + ['role' => 'user', 'content' => str_repeat('z', 4_000)], + ]; + + $budget = new ContextBudget(200); + + self::assertSame($enorme, $budget->fit($enorme)); + self::assertGreaterThan($budget->ceiling(), $budget->estimate($enorme)); + } + + /** + * @return list> + */ + private function longConversation(int $turns): array + { + $messages = [['role' => 'system', 'content' => 'Sois concis.']]; + for ($turn = 0; $turn < $turns; ++$turn) { + $messages[] = ['role' => 'user', 'content' => 'Question '.$turn]; + $messages[] = ['role' => 'assistant', 'content' => 'Réponse '.$turn.' '.str_repeat('y', 30)]; + } + + // Le dernier message est un `user` : c'est le tour auquel le modèle doit répondre. + $messages[] = ['role' => 'user', 'content' => 'Question '.$turns]; + + return $messages; + } +} diff --git a/symfony/tests/Unit/Ai/ConversationTest.php b/symfony/tests/Unit/Ai/ConversationTest.php new file mode 100644 index 000000000..db413afee --- /dev/null +++ b/symfony/tests/Unit/Ai/ConversationTest.php @@ -0,0 +1,73 @@ + 'system', 'content' => 'Sois concis.'], + ['role' => 'user', 'content' => 'Bonjour'], + ['role' => 'assistant', 'content' => null, 'tool_calls' => [['id' => 'c1']]], + ['role' => 'tool', 'content' => 'ok', 'tool_call_id' => 'c1'], + ['role' => 'assistant', 'content' => 'Voilà'], + ['role' => 'user', 'content' => 'Merci'], + ]; + + self::assertSame($messages, Conversation::fromWire($messages)->toWire()); + } + + public function testATurnGathersEverythingTheAgentProducedInAnswer(): void + { + $conversation = Conversation::fromWire([ + ['role' => 'system', 'content' => 'S'], + ['role' => 'user', 'content' => 'u1'], + ['role' => 'assistant', 'content' => null, 'tool_calls' => [['id' => 'c1']]], + ['role' => 'tool', 'content' => 'r1', 'tool_call_id' => 'c1'], + ['role' => 'assistant', 'content' => 'a1'], + ['role' => 'user', 'content' => 'u2'], + ]); + + self::assertCount(2, $conversation->turns); + self::assertSame(4, $conversation->turns[0]->count(), 'Le tour doit tenir l’appel d’outil et son résultat.'); + self::assertCount(1, $conversation->system); + } + + public function testTheLastTurnIsNeverDropped(): void + { + $conversation = Conversation::fromWire([ + ['role' => 'system', 'content' => 'S'], + ['role' => 'user', 'content' => 'u1'], + ]); + + self::assertSame($conversation, $conversation->withoutOldestTurn()); + } + + /** + * Un marqueur de compaction posé au fil des tours n'est pas du préambule : le rattacher au + * système le ferait remonter en tête à chaque passage, et il finirait par en avoir plusieurs. + */ + public function testOnlyLeadingSystemMessagesArePreamble(): void + { + $conversation = Conversation::fromWire([ + ['role' => 'system', 'content' => 'S'], + ['role' => 'user', 'content' => 'u1'], + ['role' => 'system', 'content' => '[messages retirés]'], + ['role' => 'user', 'content' => 'u2'], + ]); + + self::assertCount(1, $conversation->system); + self::assertCount(2, $conversation->turns); + } +} diff --git a/symfony/tests/Unit/Ai/DelegationCeilingTest.php b/symfony/tests/Unit/Ai/DelegationCeilingTest.php new file mode 100644 index 000000000..9e68578c3 --- /dev/null +++ b/symfony/tests/Unit/Ai/DelegationCeilingTest.php @@ -0,0 +1,141 @@ +loosens(AgentMode::Standard)); + self::assertTrue(AgentMode::Edition->loosens(AgentMode::Standard)); + self::assertFalse(AgentMode::Standard->loosens(AgentMode::Auto)); + self::assertFalse(AgentMode::Standard->loosens(AgentMode::Standard)); + } + + /** + * Le mode demandé au démarrage se plie au plafond — et on le lit **à travers la garde**, seul + * endroit où un mode veut dire quelque chose : l'outil externe part-il, ou attend-il un accord + * que personne ne donnera ? + */ + public function testAnAgentStartedAboveItsCeilingIsClampedDown(): void + { + self::assertFalse( + $this->externalToolRan(demande: 'auto', plafond: 'standard'), + 'Un agent délégué a envoyé un courriel que la garde de son parent lui refusait.', + ); + } + + /** + * La moitié qu'on oublie : le plafond vaut **aussi après**. Un sous-agent qui accepterait + * `set_mode: auto` n'aurait pas de plafond du tout. + */ + public function testASetModeSignalCannotLoosenPastTheCeiling(): void + { + self::assertFalse( + $this->externalToolRan(demande: 'standard', plafond: 'standard', signale: 'auto'), + 'Un signal a desserré le plafond ; la garde est décorative.', + ); + } + + /** + * Le témoin : sans plafond, exactement le même scénario laisse passer. Sans lui, les deux + * assertions ci-dessus passeraient même si la garde bloquait tout pour une autre raison. + */ + public function testWithoutACeilingTheSameScenarioGoesThrough(): void + { + self::assertTrue( + $this->externalToolRan(demande: 'auto', plafond: 'auto'), + 'Le témoin ne passe pas : le test ne prouve rien sur le plafond.', + ); + } + + public function testTheDelegationToolIsOfferedAndHarmlessByItself(): void + { + $definition = DelegateTool::definition(); + + self::assertSame('deleguer', $definition->name); + self::assertFalse( + AgentMode::Standard->requiresApprovalFor($definition->effect), + 'Déléguer n\'écrit nulle part : c\'est ce que fait le délégué qui repasse par une garde.', + ); + } + + /** + * Fait tourner un agent à qui le modèle demande un envoi de courriel — un effet `external`, le + * cas que seule `auto` laisse passer — et dit si l'activité d'outil a réellement tourné. + */ + private function externalToolRan(string $demande, string $plafond, ?string $signale = null): bool + { + $tours = 0; + $execute = false; + + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function () use (&$tours): array { + if (0 === $tours++) { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => 'c1', + 'type' => 'function', + 'function' => ['name' => 'send_email', 'arguments' => '{"to":"x@example.test","body":"x"}'], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + return ['choices' => [['message' => ['content' => 'fini'], 'finish_reason' => 'stop']]]; + }, + 'ai_tool_call' => static function () use (&$execute): string { + $execute = true; + + return 'envoyé'; + }, + ]); + + $executionId = \sprintf('ceiling-%s-%s-%s', $demande, $plafond, $signale ?? 'rien'); + $environment->getEventStore()->append(new ExecutionStarted($executionId, [])); + if (null !== $signale) { + $environment->getEventStore()->append(new WorkflowSignalReceived($executionId, 'set_mode', ['mode' => $signale])); + } + + $environment->runWorkflowClass(DurableAgentWorkflow::class, [ + 'tools' => ['send_email' => ['description' => 'Envoie un courriel.', 'effect' => 'external', 'parameters' => []]], + 'prompt' => 'Envoie le compte rendu', + 'maxTurns' => 1, + 'mode' => $demande, + 'modeCeiling' => $plafond, + // Personne ne validera : l'attente doit tomber vite pour que le test tienne en + // millisecondes plutôt qu'en quart d'heure. + 'humanTimeoutSeconds' => 0.01, + ], $executionId); + + return $execute; + } +} diff --git a/symfony/tests/Unit/Ai/DurableAgentReplayTest.php b/symfony/tests/Unit/Ai/DurableAgentReplayTest.php new file mode 100644 index 000000000..8d11165b3 --- /dev/null +++ b/symfony/tests/Unit/Ai/DurableAgentReplayTest.php @@ -0,0 +1,171 @@ + [ + 'description' => 'Météo courante d\'une ville.', + 'parameters' => [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + ], + ]; + + public function testTheToolCallingLoopReplaysWithoutReinvokingTheModel(): void + { + [$result, $modelCalls, $toolCalls, $passes] = $this->executeAgent('exec-1'); + + self::assertSame('Paris 22°C, Lyon 25°C.', $result); + + // Sans ça, le reste ne prouve rien : il faut que le code du workflow — donc + // `Runner::run()` et la boucle d'appel d'outils — ait bien été réexécuté. + self::assertGreaterThan(3, $passes, 'Le workflow n\'a pas été rejoué ; le test ne prouve rien.'); + + // Le journal court-circuite le rejeu : trois tours de modèle, pas un de plus, alors que le + // code du workflow a été réexécuté à chaque reprise. + self::assertCount(3, $modelCalls, 'Le rejeu a redemandé le modèle.'); + self::assertCount(2, $toolCalls, 'Le rejeu a ré-exécuté un outil.'); + } + + public function testTheOutboundPayloadsAreIdenticalAcrossTwoIndependentRuns(): void + { + [, $first] = $this->executeAgent('exec-a'); + [, $second] = $this->executeAgent('exec-b'); + + // L'assertion qui compte. Le journal est indexé par position de curseur, pas par contenu : + // les comptages ci-dessus passeraient même si le payload avait changé. Un UUID de message + // qui fuit dans la charge, un toolbox relu du conteneur, et c'est ici que ça rougit. + self::assertSame( + json_encode($first, \JSON_PRETTY_PRINT), + json_encode($second, \JSON_PRETTY_PRINT), + 'Le code du workflow n\'est pas déterministe : deux exécutions produisent des payloads différents.', + ); + } + + /** + * Un tour d'appel d'outil **ne peut pas** porter de raisonnement à travers ce pont, et c'est + * une contrainte du fournisseur, pas un oubli : `CompletionsConversionTrait::convertChoice()` + * rend un `ToolCallResult` nu dès que `finish_reason` vaut `tool_calls`, sans regarder autre + * chose. Le raisonnement d'un tour outillé est donc perdu à la frontière. + * + * Le test est là pour que ça se voie le jour où le pont changera d'avis : c'est un point de + * changement, pas un détail — corriger le convertisseur ferait diverger toute exécution en vol. + */ + public function testAToolCallingTurnCarriesNoReasoningThroughThisBridge(): void + { + [, $modelCalls] = $this->executeAgent('exec-tool-reasoning'); + + $assistantTurns = array_filter( + $modelCalls[2]['payload']['messages'], + static fn (array $m): bool => 'assistant' === ($m['role'] ?? null), + ); + + self::assertNotSame([], $assistantTurns, 'Aucun tour d\'assistant n\'est reparti au modèle.'); + foreach ($assistantTurns as $turn) { + self::assertArrayNotHasKey('reasoning_content', $turn, 'Le contrat générique a repris la main : Mistral répond 422 là-dessus.'); + } + } + + /** + * Un tour raisonné arrive en `MultiPartResult` — le convertisseur Mistral rend un + * `ThinkingResult` **et** un `TextResult`. `getContent()` y donne un tableau : sans + * `asText()`, le fil afficherait « Array » à la place de la réponse. + */ + public function testTheAnswerStaysTheTextEvenWhenTheTurnCarriesReasoning(): void + { + [$result] = $this->executeAgent('exec-answer'); + + self::assertSame('Paris 22°C, Lyon 25°C.', $result); + } + + /** + * @return array{0: string, 1: list>, 2: list>, 3: int} + */ + private function executeAgent(string $executionId): array + { + $modelCalls = []; + $toolCalls = []; + $passes = 0; + + $scripted = [ + $this->toolCallResponse('call_1', 'weather', ['city' => 'Paris']), + $this->toolCallResponse('call_2', 'weather', ['city' => 'Lyon']), + $this->textResponse('Paris 22°C, Lyon 25°C.', 'Les deux relevés sont là, je réponds.'), + ]; + + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function (array $payload) use (&$modelCalls, $scripted): array { + $modelCalls[] = ['model' => $payload['model'], 'payload' => $payload['payload'], 'options' => $payload['options']]; + + return $scripted[\count($modelCalls) - 1]; + }, + 'ai_tool_call' => static function (array $payload) use (&$toolCalls): string { + $toolCalls[] = $payload; + + return \sprintf('%s: 22°C', $payload['arguments']['city']); + }, + ]); + + $result = $environment->run( + static function ($workflowEnvironment) use (&$passes): string { + ++$passes; + + return (new DurableAgentWorkflow($workflowEnvironment))->run( + self::TOOLS, + prompt: 'Météo à Paris et à Lyon ?', + mode: 'auto', + maxTurns: 1, + ); + }, + $executionId, + ); + + return [$result, $modelCalls, $toolCalls, $passes]; + } + + /** + * @param array $arguments + * + * @return array + */ + private function toolCallResponse(string $id, string $name, array $arguments): array + { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => $id, + 'type' => 'function', + 'function' => ['name' => $name, 'arguments' => json_encode($arguments)], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + /** + * @return array + */ + private function textResponse(string $text, string $reasoning = ''): array + { + return ['choices' => [['message' => [ + 'content' => '' === $reasoning ? $text : [ + ['type' => 'thinking', 'thinking' => [['type' => 'text', 'text' => $reasoning]]], + ['type' => 'text', 'text' => $text], + ], + ], 'finish_reason' => 'stop']]]; + } +} diff --git a/symfony/tests/Unit/Ai/ToolApprovalGateTest.php b/symfony/tests/Unit/Ai/ToolApprovalGateTest.php new file mode 100644 index 000000000..5b22209d6 --- /dev/null +++ b/symfony/tests/Unit/Ai/ToolApprovalGateTest.php @@ -0,0 +1,58 @@ +decide('accorde', true); + $gate->decide('refuse', false); + $gate->timeout('expire'); + + self::assertSame(ApprovalOutcome::Approved, $gate->outcome('accorde')); + self::assertSame(ApprovalOutcome::Refused, $gate->outcome('refuse')); + self::assertSame(ApprovalOutcome::Expired, $gate->outcome('expire')); + self::assertNull($gate->outcome('jamais-demande')); + } + + public function testAskingPutsTheCallInPendingUntilItIsSettled(): void + { + $gate = new ToolApprovalGate(); + $gate->ask(new ToolCall('c1', 'send_email', ['to' => 'a@b.test']), 'demande une validation'); + + self::assertFalse($gate->isSettled('c1')); + self::assertCount(1, $gate->pending()); + self::assertSame('send_email', $gate->pending()[0]->tool); + + $gate->timeout('c1'); + + self::assertTrue($gate->isSettled('c1')); + self::assertSame([], $gate->pending()); + } + + /** + * Un signal arrivé après le tir de l'échéance ne doit pas ressusciter un appel déjà tranché : + * au rejeu, l'ordre du journal donnerait le verdict inverse. + */ + public function testTheFirstOutcomeWins(): void + { + $gate = new ToolApprovalGate(); + + $gate->timeout('c1'); + $gate->decide('c1', true); + + self::assertSame(ApprovalOutcome::Expired, $gate->outcome('c1')); + } +} diff --git a/symfony/tests/Unit/Ai/ToolGuardTest.php b/symfony/tests/Unit/Ai/ToolGuardTest.php new file mode 100644 index 000000000..b26916680 --- /dev/null +++ b/symfony/tests/Unit/Ai/ToolGuardTest.php @@ -0,0 +1,148 @@ + [AgentMode::Auto, 'send_email', false]; + yield 'auto laisse passer une écriture' => [AgentMode::Auto, 'save_note', false]; + yield 'edition laisse passer une écriture' => [AgentMode::Edition, 'save_note', false]; + yield 'edition demande pour un effet externe' => [AgentMode::Edition, 'send_email', true]; + yield 'standard laisse passer une lecture' => [AgentMode::Standard, 'weather', false]; + yield 'standard demande pour une écriture' => [AgentMode::Standard, 'save_note', true]; + yield 'standard demande pour un effet externe' => [AgentMode::Standard, 'send_email', true]; + // Le défaut prudent : un outil non classé est traité comme externe. + yield 'un outil inconnu est traité comme externe' => [AgentMode::Standard, 'rm_rf', true]; + } + + #[DataProvider('matrix')] + public function testTheModeDecidesFromTheDeclaredEffect(AgentMode $mode, string $tool, bool $needsApproval): void + { + $decision = (new ModeToolGuard(self::tools()))->decide(new ToolCall('c1', $tool), $mode); + + self::assertSame($needsApproval, $decision->needsApproval()); + self::assertSame(!$needsApproval, $decision->isAllowed()); + } + + public function testADenyListWinsOverEveryMode(): void + { + $guard = new ModeToolGuard(self::tools(), ['send_email']); + + self::assertTrue($guard->decide(new ToolCall('c1', 'send_email'), AgentMode::Auto)->isDenied()); + } + + /** + * Sans réponse avant l'échéance, la demande tombe — et elle tombe du côté sûr : refus. + * L'horloge virtuelle du runner in-memory avance d'échéance en échéance, donc le minuteur tire + * sans attendre réellement. + */ + public function testAnApprovalThatIsNeverAnsweredExpiresAsARefusal(): void + { + $toolCalls = 0; + $round = 0; + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function (array $payload) use (&$round): array { + if (0 === $round++) { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => 'call_1', + 'type' => 'function', + 'function' => ['name' => 'send_email', 'arguments' => '{"to":"a@b.test"}'], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + $last = end($payload['payload']['messages']); + + return ['choices' => [['message' => ['content' => $last['content']], 'finish_reason' => 'stop']]]; + }, + 'ai_tool_call' => static function (array $payload) use (&$toolCalls): string { + ++$toolCalls; + + return 'envoyé'; + }, + ]); + + $result = $environment->run( + static fn ($workflowEnvironment): string => (new DurableAgentWorkflow($workflowEnvironment))->run( + ['send_email' => ['description' => 'Envoi', 'effect' => 'external']], + mode: 'standard', + prompt: 'Envoie un mail', + maxTurns: 1, + humanTimeoutSeconds: 5.0, + ), + 'guard-timeout-1', + ); + + self::assertSame(0, $toolCalls, 'Une validation expirée a quand même déclenché l\'outil.'); + self::assertSame(ApprovalOutcome::Expired->message(), $result); + } + + /** + * Un refus n'est pas une exception : il redevient un résultat d'outil rendu au modèle, qui + * continue. L'activité, elle, n'est jamais planifiée. + */ + public function testADeniedToolNeverReachesItsActivityAndTheAgentKeepsGoing(): void + { + $toolCalls = 0; + $round = 0; + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function (array $payload) use (&$round): array { + if (0 === $round++) { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => 'call_1', + 'type' => 'function', + 'function' => ['name' => 'send_email', 'arguments' => '{"to":"a@b.test"}'], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + $last = end($payload['payload']['messages']); + + return ['choices' => [['message' => ['content' => 'Compris : '.$last['content']], 'finish_reason' => 'stop']]]; + }, + 'ai_tool_call' => static function (array $payload) use (&$toolCalls): string { + ++$toolCalls; + + return 'envoyé'; + }, + ]); + + $result = $environment->run( + static fn ($workflowEnvironment): string => (new DurableAgentWorkflow($workflowEnvironment))->run( + ['send_email' => ['description' => 'Envoi', 'effect' => 'external']], + prompt: 'Envoie un mail', + maxTurns: 1, + guard: new ModeToolGuard(self::tools(), ['send_email']), + ), + 'guard-deny-1', + ); + + self::assertSame(0, $toolCalls, 'L\'activité d\'un outil refusé a quand même été planifiée.'); + self::assertStringContainsString('interdit par la politique', $result); + } +} diff --git a/symfony/tests/Unit/Ai/WatchRouterTest.php b/symfony/tests/Unit/Ai/WatchRouterTest.php new file mode 100644 index 000000000..c38310086 --- /dev/null +++ b/symfony/tests/Unit/Ai/WatchRouterTest.php @@ -0,0 +1,237 @@ +}> */ + private array $envoyes = []; + + private InMemoryEventStore $eventStore; + + protected function setUp(): void + { + $this->eventStore = new InMemoryEventStore(); + $this->envoyes = []; + } + + public function testItWakesOnlyTheWatchesOfThatSubject(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::CommandeExpediee); + $this->armWatch('exec-b', 'w2', WatchSubject::PaiementRecu); + + $reveilles = $this->router(['exec-a', 'exec-b'])->dispatch(new BusinessEvent(WatchSubject::CommandeExpediee)); + + self::assertSame(['exec-a'], $reveilles); + self::assertCount(1, $this->envoyes); + self::assertSame('alerte', $this->envoyes[0]['signal']); + self::assertSame('w1', $this->envoyes[0]['payload']['callId']); + } + + /** + * Un fait n'appartient à personne : deux conversations qui attendent la même chose la + * reçoivent toutes les deux. + */ + public function testEveryExecutionWatchingTheSubjectIsWoken(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::ImportTermine); + $this->armWatch('exec-b', 'w2', WatchSubject::ImportTermine); + + $reveilles = $this->router(['exec-a', 'exec-b'])->dispatch(new BusinessEvent(WatchSubject::ImportTermine)); + + self::assertSame(['exec-a', 'exec-b'], $reveilles); + } + + /** + * Les précisions de l'événement font le texte que l'agent relira ; le sujet, lui, ne sert qu'à + * l'appariement. + */ + public function testTheEventDetailsReachTheAgentAsReadableText(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::CommandeExpediee); + + $this->router(['exec-a'])->dispatch(new BusinessEvent(WatchSubject::CommandeExpediee, ['numero' => 'CMD-42'])); + + self::assertStringContainsString('CMD-42', $this->envoyes[0]['payload']['observation']); + } + + public function testAnExecutionWatchingNothingIsLeftAlone(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::CommandeExpediee); + + $this->router(['exec-a'])->dispatch(new BusinessEvent(WatchSubject::StockReappro)); + + self::assertSame([], $this->envoyes); + } + + /** + * Le catalogue rend l'identifiant Temporal, la projection veut celui de l'exécution : sans le + * retrait du préfixe, on lirait le journal d'une exécution qui n'existe pas. + */ + public function testARunWithoutTheTemporalPrefixIsIgnored(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::CommandeExpediee); + + $catalogue = $this->catalog([new WorkflowRunDescription( + runId: 'r1', + workflowName: DurableAgentWorkflow::TYPE, + status: WorkflowRunStatus::Running, + groupId: 'exec-a', + )]); + + (new WatchRouter($this->transcript(), $this->signals(), $catalogue)) + ->dispatch(new BusinessEvent(WatchSubject::CommandeExpediee)); + + self::assertSame([], $this->envoyes); + } + + public function testAnotherWorkflowTypeIsNotScanned(): void + { + $this->armWatch('exec-a', 'w1', WatchSubject::CommandeExpediee); + + $catalogue = $this->catalog([new WorkflowRunDescription( + runId: 'r1', + workflowName: 'Samples_BookingSaga', + status: WorkflowRunStatus::Running, + groupId: 'durable-exec-a', + )]); + + (new WatchRouter($this->transcript(), $this->signals(), $catalogue)) + ->dispatch(new BusinessEvent(WatchSubject::CommandeExpediee)); + + self::assertSame([], $this->envoyes); + } + + /** + * Une veille est lisible au journal dès que l'appel d'outil y est planifié : la projection la + * reconstruit de là, comme le fait le fil. + */ + private function armWatch(string $executionId, string $callId, WatchSubject $subject): void + { + $this->eventStore->append(new ActivityScheduled($executionId, 'a1', 'ai_model_invoke', [ + 'payload' => ['messages' => [['role' => 'user', 'content' => 'Surveille']]], + ])); + $this->eventStore->append(new ActivityCompleted($executionId, 'a1', [ + 'choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => $callId, + 'type' => 'function', + 'function' => ['name' => WatchTool::TOOL, 'arguments' => json_encode([ + 'sujet' => $subject->value, + 'observation' => 'Ce que je guette', + 'intention' => 'Ce que j’en ferai', + ], \JSON_UNESCAPED_UNICODE)], + ]]], 'finish_reason' => 'tool_calls']], + ])); + } + + /** + * @param list $executionIds + */ + private function router(array $executionIds): WatchRouter + { + return new WatchRouter($this->transcript(), $this->signals(), $this->catalog(array_map( + static fn (string $id): WorkflowRunDescription => new WorkflowRunDescription( + runId: 'run-' . $id, + workflowName: DurableAgentWorkflow::TYPE, + status: WorkflowRunStatus::Running, + groupId: 'durable-' . $id, + ), + $executionIds, + ))); + } + + private function transcript(): ChatTranscript + { + return new ChatTranscript($this->eventStore, new InMemoryWorkflowMetadataStore()); + } + + /** + * Le vrai {@see AgentSignals}, avec un bus qui enregistre et le `MockHub` du composant : ce + * qu'on veut voir, c'est le message que le chemin réel produit, pas celui qu'un bouchon aurait + * bien voulu rendre. + */ + private function signals(): AgentSignals + { + $bus = new class($this->envoyes) implements MessageBusInterface { + /** @param list}> $envoyes */ + public function __construct(private array &$envoyes) + { + } + + public function dispatch(object $message, array $stamps = []): Envelope + { + if ($message instanceof DeliverWorkflowSignalMessage) { + $this->envoyes[] = [ + 'executionId' => $message->executionId, + 'signal' => $message->signalName, + 'payload' => $message->payload, + ]; + } + + return new Envelope($message); + } + }; + + $hub = new MockHub('http://hub.test/.well-known/mercure', new StaticTokenProvider('jeton'), static fn (Update $u): string => 'urn:uuid:test'); + + return new AgentSignals($bus, new AgentLiveFeed($hub)); + } + + /** + * @param list $runs + */ + private function catalog(array $runs): WorkflowRunCatalogInterface + { + return new class($runs) implements WorkflowRunCatalogInterface { + /** @param list $runs */ + public function __construct(private readonly array $runs) {} + + public function listRuns(?WorkflowRunStatus $status = null, ?string $cursor = null, int $limit = 20): WorkflowRunPage + { + return new WorkflowRunPage($this->runs); + } + + public function readHistory(WorkflowRunDescription $run): array { return []; } + + public function checkHealth(): BackendHealth + { + return new BackendHealth('test', true, 'bouchon', new \DateTimeImmutable()); + } + }; + } +} diff --git a/symfony/tests/Unit/Ai/WatchTest.php b/symfony/tests/Unit/Ai/WatchTest.php new file mode 100644 index 000000000..998848de7 --- /dev/null +++ b/symfony/tests/Unit/Ai/WatchTest.php @@ -0,0 +1,166 @@ +scriptedModel()); + $this->alertUpFront($environment, 'watch-1', 'le camion est à quai'); + + $answer = $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'watch-1'); + + self::assertStringContainsString('le camion est à quai', $answer); + self::assertStringContainsString('Enregistrer la réception', $answer, 'Le réveil a oublié de rendre l’intention.'); + } + + /** + * Sans alerte, la veille tombe — et elle rend quand même l'intention, pour que l'agent sache + * de quoi il parle en reprenant la main. + */ + public function testAWatchNobodyRaisesExpiresAndStillRecallsTheIntention(): void + { + $environment = WorkflowTestEnvironment::inMemory($this->scriptedModel()); + + $answer = $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'watch-2'); + + self::assertStringContainsString('expiré sans alerte', $answer); + self::assertStringContainsString('Enregistrer la réception', $answer); + } + + public function testAWatchNeverReachesAnActivity(): void + { + $toolCalls = 0; + $handlers = $this->scriptedModel(); + $handlers['ai_tool_call'] = static function () use (&$toolCalls): string { + ++$toolCalls; + + return 'exécuté'; + }; + + $environment = WorkflowTestEnvironment::inMemory($handlers); + $this->alertUpFront($environment, 'watch-3', 'le camion est à quai'); + + $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'watch-3'); + + self::assertSame(0, $toolCalls); + } + + public function testTheWatchToolIsAlwaysOffered(): void + { + $offered = []; + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function (array $payload) use (&$offered): array { + foreach ($payload['options']['tools'] ?? [] as $tool) { + $offered[] = $tool['function']['name'] ?? '?'; + } + + return ['choices' => [['message' => ['content' => 'Bonjour.'], 'finish_reason' => 'stop']]]; + }, + ]); + + $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'watch-4'); + + self::assertContains(WatchTool::TOOL, $offered); + } + + private function alertUpFront(WorkflowTestEnvironment $environment, string $executionId, string $observation): void + { + $environment->getEventStore()->append(new ExecutionStarted($executionId, [])); + $environment->getEventStore()->append(new WorkflowSignalReceived( + $executionId, + 'alerte', + ['callId' => self::CALL, 'observation' => $observation], + )); + } + + /** + * @return array + */ + private function input(): array + { + return ['prompt' => 'Surveille la livraison', 'maxTurns' => 1, 'mode' => 'auto']; + } + + /** + * @return array + */ + private function scriptedModel(): array + { + $round = 0; + + return [ + 'ai_model_invoke' => static function (array $payload) use (&$round): array { + if (0 === $round++) { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => self::CALL, + 'type' => 'function', + 'function' => ['name' => WatchTool::TOOL, 'arguments' => json_encode([ + 'sujet' => 'commande.expediee', + 'observation' => 'La livraison arrive à l’entrepôt', + 'intention' => 'Enregistrer la réception et prévenir l’équipe', + 'deadlineSeconds' => 5, + ], \JSON_UNESCAPED_UNICODE)], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + $last = end($payload['payload']['messages']); + + return ['choices' => [['message' => ['content' => $last['content']], 'finish_reason' => 'stop']]]; + }, + ]; + } + + /** + * Un sujet hors du vocabulaire ne doit pas armer une veille : rien ne pourrait jamais la + * lever, et l'agent dormirait jusqu'à son échéance sans que personne ne le sache. + */ + public function testAnUnknownSubjectIsRefusedInsteadOfArmingADeadWatch(): void + { + $round = 0; + $environment = WorkflowTestEnvironment::inMemory([ + 'ai_model_invoke' => static function (array $payload) use (&$round): array { + if (0 === $round++) { + return ['choices' => [['message' => ['content' => null, 'tool_calls' => [[ + 'id' => self::CALL, + 'type' => 'function', + 'function' => ['name' => WatchTool::TOOL, 'arguments' => json_encode([ + 'sujet' => 'livraison.arrivee', + 'observation' => 'Le camion', + 'intention' => 'Prévenir', + ], \JSON_UNESCAPED_UNICODE)], + ]]], 'finish_reason' => 'tool_calls']]]; + } + + $last = end($payload['payload']['messages']); + + return ['choices' => [['message' => ['content' => $last['content']], 'finish_reason' => 'stop']]]; + }, + ]); + + $answer = $environment->runWorkflowClass(DurableAgentWorkflow::class, $this->input(), 'watch-refus'); + + self::assertStringContainsString('Sujet de veille inconnu', $answer); + self::assertStringContainsString('commande.expediee', $answer, 'Le refus doit dire ce qui est acceptable.'); + } +}