Agents
Public visé : opérateurs Genesis comme agents Genesis. Cette page documente le sous-système des agents de bout en bout — ce que sont les agents et comment vous les pilotez (surface opérateur), puis comment fonctionnent réellement les rouages internes du dispatch, de l'ordonnancement, du budget et des appels LLM (architecture). C'est une page dense ; survolez la moitié opérateur, lisez la moitié architecture quand vous avez besoin de raisonner sur le comportement.
1. Ce qu'est un agent
Dans Genesis, un agent est un travailleur autonome à longue durée de vie qui exécute une boucle plan-puis-agit (plan-then-act) à sa propre cadence. Contrairement à un appel LLM unique, un agent :
- persiste sous forme de ligne (
agent_instances) dans la base de données de son tenant, avec un statut de cycle de vie, un mode opératoire actif et un état d'ordonnancement à auto-cadence ; - se réveille sur planification (un « tick » de battement de cœur, heartbeat) et sur événements externes (un message opérateur, une question répondue, un changement d'état de mission) ;
- raisonne une fois par tick via un unique appel LLM à provenance enregistrée, puis entreprend des actions typées (proposer une mission, escalader une décision, envoyer un message à l'opérateur) strictement par appels d'outils gouvernés ;
- se souvient de ses propres tours passés (un registre d'état en ajout-seul) et les ré-injecte comme contexte de continuité ;
- n'auto-applique jamais quoi que ce soit de risqué — chaque action conséquente est soit soumise à la gouvernance (governance-gated), soit routée vers un humain via la file de décisions.
Deux grandes familles partagent l'essentiel de cette mécanique :
- Agents permanents (standing) — cerveaux toujours actifs, à portée de tenant, qui exécutent une boucle plan-puis-agit continue : le Strategic Orchestration Agent (SOA) et le Compliance agent. Ce sont eux qui font l'objet de cette page.
- Agents ponctuels / liés (bursty / bound) — des agents qui existent pour accomplir un travail fini puis se taisent : le Prompt Engineer (une salve par retravail de call-site) et les mission agents (un cycle de vie de mission chacun).
Distinct des deux, le léger registre d'agents en mémoire (in-memory agent registry) — un annuaire de coordination des agents externes/travailleurs (coders, reviewers, monitors) utilisé pour le routage de tâches par capacité. Le même mot « agent » recouvre à la fois les cerveaux permanents lourds et les entrées du registre ; les sections ci-dessous les gardent distincts.
2. Surface opérateur
2.1 Types d'agents
Il existe deux systèmes de types complémentaires.
(a) Le discriminant persisté AgentType — le type faisant autorité d'une ligne agent_instances. Il sélectionne la politique de l'agent, son preset/site de planification, son catalogue de modes opératoires, et (pour les missions) la pompe de cycle de vie.
AgentType | Nom produit | Famille | Rôle |
|---|---|---|---|
Soa (par défaut) | Strategic Orchestration Agent | Permanent, natif | Orchestration de tenant à long horizon — passe en revue la santé du tenant + de la plateforme, propose des missions stratégiques bien cadrées, escalade les vrais embranchements vers l'opérateur. |
Compliance | Compliance agent | Permanent, natif | Cerveau réglementaire/conformité permanent — balaie la posture, recherche les cadres réglementaires de l'UE (CRA, DORA, NIS2, EU AI Act, PLD), rédige des dossiers réglementaires en double forme, escalade les décisions de conformité. N'auto-applique jamais (aucun outil de création de mission). |
PromptRedactor | Prompt Engineer | Ponctuel, lié | Retravaille les couches de prompt engagées pour un couple modèle+provider unique à un call-site unique et propose le résultat pour approbation humaine (MR seulement ; ne s'auto-approuve jamais). Le nom du membre d'enum / la chaîne en base reste PromptRedactor en tant que contrat wire/DB ; les types C# et l'UI ont été renommés en Prompt Engineer. |
MissionFeature | Mission de fonctionnalité | Mission | Mission générique productrice de code (le pipeline decompose→code→review). |
MissionTenantBootstrap | Onboarding au premier lancement | Mission | Onboarding de tenant au premier lancement. |
MissionProjectBootstrap | Onboarding de nouveau projet | Mission | Onboarding de nouveau projet pour utilisateur récurrent. |
MissionResearch | Mission de recherche | Mission | Investigation en lecture seule. |
MissionReview | Mission de review | Mission | Analyse par le review-engine. |
MissionAspectOnboarding | Onboarding d'aspect | Mission | Onboarding guidé par projet. |
MissionBugInvestigation | Investigation de bug | Mission | Investigation de support-case. |
MissionBugFix | Correction de bug | Mission | Correction de support-case. |
Les membres Mission* reflètent le « kind » de mission un pour un. Une mission « s'exécute en tant que type d'agent » uniquement à la couture politique + invocation LLM — son cycle de vie reste piloté par la pompe de mission distincte sur les tables missions / mission_event_queue. Les agents permanents natifs (Soa, Compliance, PromptRedactor) s'exécutent directement sur agent_instances / agent_event_queue.
(b) Le rôle dans le registre en mémoire — un annuaire plus léger pour la coordination et le routage. Quand vous appelez agent_register, vous fournissez un role libre (Developer, Reviewer, Coordinator, Monitor ou Generic) plus des capacités. Ces agents de registre sont la façon dont les coders et autres travailleurs se déclarent afin que des tâches puissent leur être routées par capacité. Les entrées du registre sont éphémères (en-processus, partitionnées par tenant, expirées par heartbeat après 5 minutes) ; les lignes agent_instances sont durables.
2.2 Enregistrement & découverte (le registre de coordination)
Ces outils MCP gèrent le registre en mémoire (ils font proxy du contrôleur /api/v1/agents de Core) :
| Outil | Fonction |
|---|---|
agent_register | Enregistrer un agent : name, role, capabilities séparées par virgules, endpoint optionnel, lifecycleKind (manual/docker/process/aspire), transportKind (in-memory/rabbitmq/http), transportTarget, containerId, labels JSON. |
agent_list | Lister les agents enregistrés, filtrables par role / capacité / statut / lifecycle / transport. |
agent_list_available | Lister uniquement les agents actifs (heartbeat dans les 5 dernières min, non hors-ligne) actuellement libres pour affectation. |
agent_discover | Découverte scorée par capacité — classe par compétence, langage et correspondance de framework (minProficiency 1–5). Le point d'entrée du routage intelligent. |
agent_heartbeat | Maintenir un agent en vie (réinitialise le minuteur hors-ligne de 5 minutes). |
agent_deregister | Retirer un agent du registre. |
agent_route_task | Router une tâche vers le meilleur agent par correspondance de capacité (capability/language/framework/priority) et la dispatcher sur son transport, ou forcer un targetAgentId. |
agent_message_queue_depth | Inspecter le nombre de messages en attente d'un agent. |
message_send / message_receive | Messagerie point-à-point ou diffusion (*) entre agents du registre. |
task_assign / task_status | Créer/affecter une tâche à un agent du registre et interroger le statut de la tâche. |
Le SOA permanent s'enregistre ici automatiquement (role/type orchestration) afin d'apparaître sur le tableau de bord /agents et de pouvoir recevoir les push de tours en direct ; cet enregistrement est une commodité d'affichage UI, pas le control-plane du SOA.
2.3 Lancer et piloter des coders
Les coders sont des travailleurs de codage conteneurisés. Deux couches :
Coders à la demande (OnDemandCoderMcpTools, tenant-admin, proxy /api/v1/coders/on-demand) :
| Outil | Fonction |
|---|---|
coder_spawn | Mettre en place un coder pour votre tenant : tool (p. ex. claude, copilot — doit être activé au niveau du fleet), variant (par défaut standard), projectId optionnel (affichage/intention seulement), sessionId pour reprendre une session antérieure (--resume), idleTtlMinutes (par défaut 60). Il survit au scale-to-zero par inactivité de file et s'auto-tue après le TTL d'inactivité. |
coder_list | Lister les coders à la demande de votre tenant (name, tool, variant, state, TTL d'inactivité, id de session de reprise, project). |
coder_kill | Tuer un coder par nom de conteneur (votre tenant uniquement). |
Coding tasks (CodingTaskMcpTools, tenant-admin) — l'unité de travail réelle qu'un coder exécute :
| Outil | Fonction |
|---|---|
coding_task_dispatch | Mettre en file une coding task : taskType (code/test/review/refactor), prompt, context optionnel, targetPath, branch, maxTurns (par défaut 100, max 1000), enableBrowserMcp/enableEmulatorMcp/enableWindowsMcp (attacher un pool de ressources à la tâche ; false par défaut), coderVariant (saveur d'image standard/ide — browser/android sont retirés, utilisez les flags de pool), coderTool (claude/copilot/codex/gemini/aider/octofriend/custom), model/provider/backend ou un presetId, et conversationId+attachmentIds optionnels pour livrer les fichiers uploadés en chat dans le workspace du coder. Asynchrone — interrogez le statut. |
coding_task_status | Vérifier une tâche ; renvoie le résultat une fois terminée. |
coding_task_list | Lister les tâches en attente + récemment terminées. |
coding_task_extend | Reprendre une tâche ayant enregistré une session de coder avec des tours supplémentaires (1–1000) et d'éventuelles instructions supplémentaires ; continue depuis cette session. La condition préalable est la session, pas le statut — en pratique TurnLimitReached. Failed est refusé en pratique (le harness route vers TurnLimitReached toute exécution ayant produit une session, donc une exécution échouée est morte avant qu'il en existe une) ; une exécution limitée en tours tuée par le mur temporel n'en a pas non plus. Les deux refus nomment le cas ; lancez une nouvelle tâche à la place. |
coding_task_session_info | Tours utilisés / tours max / id de session pour la reprise. |
Le model/provider/backend d'une coding task se résout via la même mécanique de preset que tout le reste (voir §3.7) ; presetId prime sur le triplet explicite model/provider/backend.
Voie Windows (WindowsMcpTools, derrière la feature windows-pool, attachée avec enableWindowsMcp) — un coder loue un worker Windows au premier usage (pas de verbe de lease ; la session est tenue d'un appel à l'autre jusqu'à ce que windows_release la rende, que la tâche soit réglée ou que le bail expire) et le pilote selon deux formes. La forme build : windows_run_spec (soumettre un script, recevoir un handle de tâche) + windows_task_status (interroger). La forme bureau (ARDS-857), qui pilote une vraie UI Automation sur la machine louée comme les outils browser pilotent une page : windows_launch (démarrer un programme, recevoir {pid, hwnd, title}), windows_windows (les fenêtres de premier niveau visibles avec leur hwnd et leurs bounds), windows_focus / windows_close, windows_snapshot (l'arbre UI Automation en texte indenté, un nœud par ligne avec un ref=e<N> valable jusqu'au snapshot suivant), windows_click et windows_type (cible par ref, sinon automationId / name / controlType, sinon un point écran ; le method renvoyé dit si c'est le pattern propre de l'élément ou un vrai clic souris / des frappes qui ont agi), windows_key (des accords comme Ctrl+S, Alt+F4), windows_wait_for (un élément ou un titre de fenêtre atteignant exists / enabled / gone), windows_computer (souris / clavier bruts en pixels de l'écran principal) et windows_screenshot (l'écran principal, ou recadré sur un hwnd ; ses pixels sont les coordonnées que prend windows_computer). windows_release rend aussitôt la machine louée au pool : il met fin à la session (un build windows_run_spec encore en cours dessus est annulé et les programmes qui y ont été lancés sont fermés), l'appel windows_* suivant en loue une nouvelle, et il est idempotent — sans rien de tenu il répond released: false (outcome: not_held) et ne change rien. windows_type prend exactement un de text / secretRef : un secretRef nomme un secret que l'opérateur a provisionné sur le pool Windows (un identifiant, une clé de licence) — le pool tape sa valeur sur la machine et la valeur n'atteint jamais le coder. Un secretRef ne peut être tapé que dans un contrôle masqué de type mot de passe : visé sur autre chose, le guest refuse sans rien taper (SECRET_TARGET_NOT_MASKED), et sur un appel qui portait une saisie le corps d'erreur du pool parvient au coder projeté sur {error, reason, state, secretRef, host} — jamais le texte libre du guest. windows_screenshot avec un hwnd est une lecture pure — la fenêtre n'est pas ramenée au premier plan et une zone recouverte montre ce qui est au-dessus ; windows_focus d'abord quand cela compte. Quand la session Windows d'un coder est libérée (par windows_release, expirée, évincée, ou la tâche réglée), le pool fait fermer par le guest chaque processus que la session a démarré avec windows_launch avant que le slot soit réutilisé, de sorte que rien de lancé par un coder ne survit à son bail. windows_wait_for.timeoutMs est borné à 10 minutes et windows_launch.waitForWindowMs à 55 s — le plafond propre du guest, ordonné guest < pool < core (le budget de lancement du pool est l'attente + 10 s), de sorte qu'un programme qui n'a montré aucune fenêtre à l'échéance revient comme le hwnd: null honnête du guest, jamais comme UI_TIMEOUT (tous deux bornés, pas refusés). Chaque erreur renvoyée par un verbe Windows porte l'un de ces codes. FEATURE_DISABLED : la feature windows-pool est désactivée pour ce tenant — chaque verbe, avant tout bail ; un opérateur l'active. VALIDATION_ERROR : windows_type a reçu ni l'un ni l'autre, ou les deux, de text / secretRef, ou un secretRef qui n'est pas un nom nu — corriger l'appel, aucune machine n'a été louée. Un hôte sans bureau interactif répond DESKTOP_UNAVAILABLE à chaque verbe bureau avec la raison donnée par le pool : session_0 est une installation compile-only (aucune relance n'y change rien) ; capture_probe_failed signifie que la session interactive de l'hôte a cessé de produire des images (son client RDP s'est déconnecté ou a été réduit) — un opérateur doit restaurer cette fenêtre, après quoi le pool re-sonde le bureau sous 30 s ; ne relancer qu'après l'intervention de l'opérateur ; ELEMENT_NOT_FOUND signifie qu'il faut re-snapshoter, WINDOW_NOT_FOUND que ce hwnd n'existe plus (relister avec windows_windows), WAIT_TIMEOUT porte le temps écoulé, UI_TIMEOUT signifie que le guest n'a pas répondu dans le budget par appel du pool (une boîte de dialogue modale bloque peut-être le bureau — windows_screenshot la montre ; relançable), SECRET_NOT_FOUND nomme le secret manquant pour l'opérateur, WINDOW_NOT_FOREGROUND signifie qu'il faut donner le focus à la cible (ou écarter ce qui la recouvre) et réessayer, INPUT_BLOCKED qu'une fenêtre de plus haute intégrité ou un bureau verrouillé refuse la saisie. Tout le reste relève du vocabulaire générique du pool, partagé avec les voies browser et émulateur : POOL_UNAVAILABLE (aucune réponse HTTP — le pool est injoignable ou l'appel a dépassé le timeout HTTP de core ; réessayer), POOL_ERROR (le pool a répondu 5xx ; à saturation il nomme le ou les détenteurs actuels, pour distinguer un pool occupé d'un bail qui a fui ; réessayer), UNAUTHENTICATED / FORBIDDEN (le pool a refusé l'identifiant de core — un opérateur corrige le secret resource-pool-auth ; aucune relance n'aide), NOT_FOUND avec sessionEvicted: true (le pool ne connaît plus la session : le bail est abandonné, l'état de la machine est perdu, l'appel suivant en loue une neuve), CONFLICT (un 409 hors du vocabulaire bureau, par exemple un windows_run_spec alors qu'une tâche tourne déjà — son taskHandle est dans poolDetail), RATE_LIMITED (429 ; réessayer plus tard) et BAD_REQUEST (tout autre refus — un nom de touche inconnu dans windows_key, par exemple). Le data de chaque erreur porte httpStatus, retryable et le corps propre du pool en poolDetail.
2.4 Human-in-the-loop (HITL)
Les agents ne prennent jamais silencieusement des décisions irréversibles. Il existe deux canaux HITL :
La file de décisions (escalade d'agent). Quand un agent permanent a besoin d'une décision humaine, il lève une AttentionRequest via soa_propose_decision (ou, en interne, soa_propose_mode_change pour un changement de mode). Cela :
- est soumis au contrôle de la constitution (constitution-gated) avant sa création (une violation est réinjectée à l'agent, non levée en exception) ;
- apparaît sur la file de décisions de l'opérateur (les outils / le tableau de bord
decision_*) comme une requête d'origine SOA portant{ source: "soa", soaInstanceId }; - met en pause le travail concerné jusqu'à ce que l'opérateur réponde (
decision_respond).
Quand l'opérateur la résout, AgentControlService.EnqueueOperatorAnsweredEscalationAsync met en file un événement OperatorAnsweredEscalation vers l'instance d'origine exacte, portant la resolution + les resolutionNotes de l'opérateur. L'agent reprend depuis cet événement à son tick suivant. L'aller-retour est idempotent (un index partiel unique déduplique les résolutions concurrentes REST-puis-MCP). Point crucial : l'agent ne met jamais ses propres événements en file — le côté contrôle possède chaque mise en file.
Chat opérateur. soa_send_message (agent→opérateur) et la réponse de l'opérateur (qui arrive comme un événement OperatorMessage) forment un canal de chat non bloquant, persisté comme transcript durable et diffusé en direct sur la page de détail de l'agent.
HITL de mission (mission_ask_user). La famille mission a son analogue : un sous-agent de mission peut appeler mission_ask_user pour poser une question qui remonte à l'opérateur et réinjecte la réponse comme guidage de raffinement depuis lequel la mission reprend. C'est l'équivalent côté mission de la boucle soa_propose_decision → OperatorAnsweredEscalation du SOA.
2.5 Modes opératoires, presets & call-sites
Les modes opératoires façonnent ce sur quoi un agent se concentre et quels outils il peut utiliser cet épisode. Chaque type d'agent a son propre catalogue de modes semé (adossé à une table, avec repli sur seed en code) :
- SOA (six modes) :
Strategic(par défaut),Incident,Dialogue,Housekeeping,Idle,Discovery. - Compliance (trois) :
Cadence(par défaut),GateReview,Research. - Prompt Engineer (un) :
Redact.
L'agent ne peut pas changer son propre mode (rail R3/R7). Le mode est écrit uniquement par un humain :
- le contrôle de mode de l'UI (
AgentControlService.SetActiveModeAsync/ les variantes Compliance + redactor par type,source=ui), ou - l'approbation d'une AttentionRequest
soa_propose_mode_change(source=approval).
Les deux chemins convergent vers un unique point d'étranglement et ajoutent une ligne d'audit à agent_mode_change_events. L'agent ne fait jamais que proposer un changement.
Presets & call-sites. Chaque appel LLM dans Genesis est attribué à un call-site nommé dans le LlmSiteRegistry statique. Les sites pertinents pour les agents :
| Id du site | Utilisé par |
|---|---|
Agent:Default | Preset parent par défaut de la famille pour les agents natifs. |
Agent:Planning | L'appel de planification/raisonnement par-tick du SOA. |
Agent:Compliance | L'appel par-tick du Compliance agent (séparé du SOA le 2026-06-24 pour pouvoir être épinglé indépendamment). |
PromptRedactor:Default / PromptRedactor:Redact | Les appels de sous-agent Document/Evaluate du Prompt Engineer. |
Agent:Pod | Le gate de classe de l'agent-pod distant (la frontière /mcp/agent). |
Un opérateur lie un preset (un bundle model+provider+backend+overlay) à un call-site (llm_site_attach_preset / _detach_preset). Un site feuille hérite de la liaison par défaut de la famille de son parent jusqu'à épinglage (p. ex. Agent:Planning → Agent:Default). C'est ainsi que vous épinglez Compliance sur un modèle différent du SOA sans toucher au code. Voir §3.7 pour la mécanique de résolution.
2.6 Pages du tableau de bord
| Route | Page | Affiche |
|---|---|---|
/soa | Tableau de bord SOA | Le Strategic Orchestration Agent : statut, start/stop/reset, chat opérateur, timeline des tours en direct, mode courant + contrôle de mode, audit des changements de mode. |
/agents | Tableau de bord des agents | Effectif des agents à travers le tenant (les cerveaux permanents + les travailleurs du registre). |
/agents/{kind}/{id} | Détail d'agent | Détail en direct par instance : registre des tours, résumés de raisonnement, modèle servi + coût par tour (push en direct SignalR), chat. |
/agents/compliance | Écran Compliance | Le Compliance agent : posture, dossiers, start/stop, mode (Cadence/GateReview/Research). |
/agents/redactor | Écran Prompt Engineer | L'instance liée du prompt-engineer par call-site, ses propositions, son mode. |
/agents/{id} | Détail du registre | Le détail d'un agent du registre. |
/fleet | Fleet de coders | Coders à la demande + config du fleet ; /coders est désormais un onglet ici. |
3. Architecture
3.1 Le modèle de données
Les agents permanents vivent entièrement sur des tables par tenant.
agent_instances — une ligne par instance d'agent (l'analogue SOA d'une ligne de mission) :
| Colonne | Signification |
|---|---|
id | PK (Guid). |
agent_type | Le discriminant AgentType (text NOT NULL DEFAULT 'Soa', stocké en chaîne). |
bound_entity_type / bound_entity_id / bound_entity_key | Couture de liaison pour les agents liés. Le Prompt Engineer se lie à un call-site via (PromptRedactor, "CallSite", siteId) — bound_entity_key est le frère en chaîne, avec un index unique partiel garantissant une instance par couple@call-site. Null pour le SOA. |
label | Libellé d'affichage. |
status | AgentInstanceStatus : Active (pompé), Paused (garé, reprenable), Closed (terminal). Seules les instances Active sont tickées. |
current_phase | Libellé de phase libre que l'agent estampille à chaque tour. |
next_event_sequence_number | Compteur monotone par instance alimentant la séquence de la file d'événements. |
lease_holder_session_id / lease_expires_at | Colonnes de bail CAS (sûreté multi-réplica). |
next_tick_at | Quand le prochain tick auto-cadencé est dû (audit ; le visible_at de la file fait autorité). |
agent_string_id | L'id de l'enregistrement de registre faisant surface à cette instance sur /agents. |
consecutive_idle_ticks | Compteur de backoff B1 (ticks inactifs depuis le dernier changement d'entrées). |
last_plan_input_hash | Hash des entrées externes du planificateur au dernier tick (la clé du court-circuit no-op). |
consecutive_blocked_turns | Compteur du garde STUCK (ticks PhaseBlocked consécutifs). |
active_mode | Le nom du mode opératoire (nullable ; défaut appliqué en code). Écrit uniquement par les chemins humains. |
is_building_starting_workset / workset_deadline_utc | Drapeau de phase fondationnelle + deadline de sûreté-coût (voir §3.6). |
created_at / updated_at | Horodatages. |
agent_event_queue — le journal d'événements par instance. Chaque ligne est un événement avec un event_kind, un payload JSON optionnel, un sequence_number, un visible_at (le gate de dû faisant autorité) et un marqueur de consommation (les événements sont consommés, non supprimés, afin que la dédup-de-reprise puisse voir l'historique). Le discriminant AgentEventKind :
AgentInitialized— premier événement d'une instance neuve (déclenche l'ouverture plan-puis-salue).AgentTick— le battement de cœur auto-planifié.OperatorMessage— un message de chat opérateur à lire au tick suivant.OperatorAnsweredEscalation— un opérateur a répondu à une décision levée par l'agent.MissionStateChanged/DecisionResolved/DependencyCompleted— signaux d'orchestration (une mission pilotée a changé de statut, une décision s'est résolue, une dépendance s'est débloquée).ExternalSignalReceived— un signal générique agnostique du type d'agent dont le payload JSON porte unAgentSignalKind(NewCouple/GuidanceUpdated/RegressionDetected/HumanRequest). Utilisé par le Prompt Engineer (l'étape 6 ne câble queHumanRequest) ; le SOA ne le reçoit jamais.
agent_state_ledger (+ agent_state_ledger_actions) — l'enregistrement des tours en ajout-seul. Chaque ligne de tour porte le kind/payload de l'événement déclencheur, le AgentDecisionKind, le payload de décision structuré ({actions, waitCondition, reasoningSummary, activeMode}), la wait-condition, le résumé de raisonnement et un lien retour vers le snapshot d'appel LLM (modèle servi + coût). Les insertions sont soumises au bail (lease-enforced). Des lignes enfants par action normalisent les actions produites par le tour.
agent_modes — le catalogue de modes par type (name, description, fragment de prompt, JSON de sous-ensemble d'outils, preset optionnel, JSON d'override de budget optionnel, drapeau de seed système). agent_mode_change_events — la piste d'audit des changements de mode. agent_messages — le transcript de chat opérateur↔agent (avec direction).
AgentDecisionKind classe chaque tour : Initialized, Heartbeat, MessageAcknowledged, EscalationResumed, NoOp (hérité), plus les kinds de boucle de planification Planned, ActedWithTool, WaitingForEvent, Escalated. Il est en ajout-seul (persisté en chaîne ; les lignes historiques doivent continuer à classer).
3.2 Le modèle de dispatch
Le AgentDispatcher est un BackgroundService hébergé qui pompe les agents permanents. Sa forme :
- Contrôlé par la fonctionnalité
standing-agents, à deux niveaux. Le dispatcher est hébergé dès que le niveau déploiement est actif (services.core.features.standingAgents.enabled, historiquement la variableAgentDispatcher:Enabled; actif par défaut au déploiement), mais il ne cadence qu'un tenant dont le niveau locataire est actif — et la valeur locataire par défaut est désactivé : chaque tenant s'inscrit depuis Paramètres → Fonctionnalités (voir Fonctionnalités activables). Au démarrage il énumère les tenants actifs et déploie une boucle de polling par tenant. Les tenants nouvellement ajoutés nécessitent un redémarrage de pod pour être pris en compte. - Bootstrap par tenant. Quand
AutoBootstrap=true, chaque tenant obtient une instance SOA singleton assurée au démarrage (le schéma n'impose pas le singleton ; l'invariant « un SOA par tenant » vit dansAgentControlService/ l'appelant du bootstrap). AvecStandingAgentsIdleByDefault=trueelle est créée Active et semée d'un événementAgentInitialized(afin d'exécuter immédiatement son ouverture plan-puis-salue, puis de rester inactive) ; sinon elle est créée Paused, en attente d'un start opérateur. À la première création, l'instance entre aussi dans la phase fondationnelle (§3.6). - Cycle de polling. À chaque
PollingInterval(par défaut 5 s) le dispatcher ouvre un scope DI par-cycle sous un scope tenant ambiant, liste les instances « nécessitant attention » et en traite jusqu'àMaxConcurrentInstancesPerTenant(par défaut 4). - Le traitement par instance est borné par le bail et passe par le superviseur de tick partagé (section suivante).
- Isolation des erreurs aux niveaux cycle et instance — une mauvaise instance ou un mauvais cycle n'arrête jamais la boucle.
Le corps à exécuter est résolu par instance selon l'AgentType via DI par clé (AgentType.Soa → AgentAgent ; AgentType.Compliance → aussi AgentAgent mais avec la politique Compliance ; PromptRedactor → PromptRedactorAgent). C'est ainsi qu'un seul dispatcher sert chaque type permanent.
Deux régimes d'exécution, un seul chemin de code. Un unique drapeau de config, AgentDispatcher:EphemeralPod, choisit le régime :
- Chaud (par défaut,
EphemeralPod=false) — la boucle de polling en-processus tique les instances directement.KeepWarm == !EphemeralPod. - Pod éphémère (
EphemeralPod=true) — la pompe in-core cesse de tiquer les instances en-processus ; des agent pods engendrés par KEDA tirent les ticks via l'endpoint/mcp/agentavecagent_tick_claim/agent_tick_complete. Le bootstrap et le semis d'événements s'exécutent toujours in-core ; seul le tick par-instance migre vers les pods.
Les deux régimes pilotent les ticks via les mêmes primitives AgentTickSupervisor, de sorte que la persistance, le heartbeat et la logique STUCK sont octet-pour-octet identiques quel que soit celui qui s'exécute.
3.3 Ordonnancement des ticks : claim → run → complete
AgentTickSupervisor factorise les primitives bail + file + passivation afin que le dispatcher chaud et le runner de pod les partagent exactement.
ClaimTickAsync :
- Acquérir le bail CAS par-instance pour un
sessionIdfrais (sauter si détenu par une autre session →null). - Retirer de la file le prochain événement dû (
visible_at <= now). Si aucun n'est dû, semer un heartbeatAgentTicksi aucun n'est en attente (premier battement / reprise de chaîne), relâcher le bail et renvoyernull. - Lire le snapshot pré-tick de l'instance (les compteurs idle/STUCK s'y appuient) et renvoyer un
TickClaim.
CompleteTickAsync est l'unique écrivain de passivation — exactement un endroit écrit phase/next-tick/compteurs par tick :
- Garder la session à l'écriture — relire l'instance et confirmer que le bail est toujours détenu par cette session (un pod dont le bail a expiré en cours d'exécution ne doit pas écraser). Si perdu, sauter l'écriture et revenir.
- Dériver le decision kind/phase (le chemin chaud transmet directement le kind/phase déjà décidé par l'agent, de sorte que la valeur persistée est octet-pour-octet identique ; le chemin pod laisse le superviseur re-dériver à partir du résultat brut via le
AgentDecisionMapperpartagé). - Détection d'inactivité — un tick est inactif quand c'était un
AgentTickdont lePlanInputHashcorrespond à celui du tick précédent.consecutive_idle_tickss'incrémente à l'inactivité, se réinitialise sinon. - Garde STUCK — un
PhaseBlockedauthentique (le planificateur a renvoyé bloqué, mappé àWaitingForEventavec phaseidle:blocked) incrémenteconsecutive_blocked_turns. Le compteur est toujours persisté ; l'arrêt ne se déclenche que quandenforceStuckGuard=true(le chemin pod). À l'arrêt àMaxConsecutiveBlockedTurns(par défaut 3) l'instance est passéeClosed, le heartbeat est supprimé (afin que la file se vide et que KEDA scale à zéro) et une AttentionRequest opérateur remonte. Le chemin chaud ne s'arrête jamais (préservant le comportement hérité). - Maintenir exactement un heartbeat — sauf en cas d'arrêt, mettre en file un unique
AgentTickfutur ànext_tick_atsi aucun n'est en attente. - Faire surface de l'instance sur
/agents(chaud seulement), écrire l'unique mise à jour de passivation, pousser le tour en direct via SignalR (chaud seulement) et relâcher le bail dansfinally.
ComputeNextHeartbeatDelay règle la cadence :
- Quand
StandingAgentIdleHeartbeatEnabled=true(par défaut), le prochain heartbeat est unStandingAgentHeartbeatIntervalfixe (par défaut 24 h) — un check-in quotidien lent immunisé contre la gigue de télémétrie. Ainsi un agent se tait juste après son salut d'ouverture et reste silencieux jusqu'à un vrai événement. - Sinon il retombe sur le backoff d'inactivité géométrique (
ComputeTickDelay) :base · BackoffFactor^priorIdle, plafonné àMaxBackoffInterval(par défaut 30 min). Il ne s'engage jamais que sur des entrées inchangées.
Dans les deux cas, les vrais événements contournent entièrement la cadence — les messages opérateur et les escalades répondues sont mis en file avec visible_at = now et réveillent l'agent au cycle suivant.
C'est la distinction « permanent vs ponctuel » en pratique : les agents permanents (SOA, Compliance) se posent sur le heartbeat fixe lent et réagissent aux événements ; les agents ponctuels (Prompt Engineer) ne sont jamais bootstrappés par le dispatcher — ils sont créés Paused sur une liaison/un signal et tickés seulement une fois activés.
3.4 La boucle de planification par-tick (AgentAgent)
AgentAgent est le corps plan-puis-agit partagé pour chaque agent de planification permanent. Par tick il ne touche pas aux baux ni à la file d'événements (le dispatcher en est propriétaire) ; il ne fait que ré-injecter, planifier et enregistrer. La séquence :
- Ré-injecter — charger la queue récente des tours (les 12 derniers tours) pour la continuité à travers les ticks / redémarrages de pod.
- Digest d'ops — sonder la santé opérationnelle « ARDS-self » (deadlocks/rollbacks DB, pics de taux d'erreur, alertes déclenchées) via
IOpsDigestProbe, bucketisée dans le hash d'entrées de plan afin qu'un vrai incident bascule le bucket et force une re-planification. - Charger l'instance — nécessaire à la fois pour le court-circuit et la résolution de mode. Si la lecture échoue, le tick avorte proprement (il ne peut pas résoudre mode/type en sûreté) et réessaie au tick suivant.
- Court-circuit no-op B1 — calculer un
PlanInputHashSHA-256 sur les seuls pilotes externes (event kind + payload + digest d'ops bucketisé ; la queue de raisonnement toujours changeante de l'agent est délibérément exclue). Si le hash correspond à celui du dernier tick et qu'une re-planification forcée n'est pas due, sauter entièrement l'appel LLM et rester inactif. Une soupape de sûreté (ForcedReplanEveryNIdleTicks, par défaut 20) force périodiquement une vraie re-planification pour rattraper la dérive. C'est le levier qui empêche les agents inactifs de re-planifier toutes les 30 s et de brûler du budget. - Préambule par-événement — piloter selon ce qui a déclenché le tick :
AgentInitialized→ survole + poste un court salut ancré puis reste inactif ;OperatorMessage→ lire et répondre ;OperatorAnsweredEscalation→ reprendre en incorporant la réponse ;AgentTick→ baseline. - Effectif des éléments ouverts (open-items roster) — les missions que cet agent a déjà créées + les escalades qu'il a levées (liées via un helper de métadonnées JSONB
soaInstanceId, servi par index). Injecté au sommet de l'objectif afin que le modèle s'ancre sur le travail en cours avant de proposer — le levier direct sur les propositions en double. - Résoudre le mode actif — lire
active_mode(défaut par type), le rechercher dans le catalogue semé, et asserter son toolset ⊆ le superset §3 en fail-closed (le résolveur lève si un mode accordait un outil hors-appartenance — il n'y a pas de filet[Authorize]sur le chemin chaud). Le toolset du mode résolu devient à la fois les outils autorisés de l'invoker et le menu d'actions annoncé de l'objectif ; son fragment de prompt est ajouté à la base invariante. - (Compliance uniquement) découverte du corpus en balayage à froid — résoudre le(s) id(s) de projet de conformité du tenant afin que l'objectif puisse nommer le corpus à interroger.
- Credit gate (B-iii) — résoudre la fenêtre de crédit hebdomadaire de planification de l'agent du tenant (par défaut 60 $/sem). Quand
CreditEnforcementEnabledet que le pool est épuisé, sauter l'appel LLM et rester inactif jusqu'à la régénération ; sinon mesurer le prélèvement. - Planifier — un appel LLM à provenance enregistrée via
ISubAgentInvoker(§3.5), avec le toolset du mode comme surface d'outils autorisée et le budget par-tick résolu (§3.6). - Mapper le résultat — précédence pilotée par histogramme (a levé une escalade →
Escalated; a pris une action à effet de bord →ActedWithTool; sinon complétion propre →Planned/MessageAcknowledged/EscalationResumedselon l'événement ; sinon bloqué →WaitingForEvent). L'échelle vit dans leAgentDecisionMapperpartagé afin que chaud et pod mappent de façon identique. - Enregistrer le tour du registre en ajout-seul (avec le snapshot de provenance en lien retour) et renvoyer l'
AgentDecision— le dispatcher persiste la phase, planifie le prochain tick et relâche le bail.
Une « salve » (burst) est donc une séquence de ces ticks : le premier (AgentInitialized) salue et reste inactif ; les ticks suivants se déclenchent sur le heartbeat fixe ou sur des événements injectés ; à l'intérieur d'un unique tick l'invoker exécute une boucle agentique multi-tours bornée (l'« épisode ») qui peut interroger la mémoire/télémétrie puis émettre une ou deux propositions avant de terminer.
3.5 Comment les agents font des appels LLM (le pipeline en-processus)
Les agents raisonnent via SubAgentInvoker — la boucle agentique manuelle de la base de code, délibérément pas le chemin d'auto-invocation du SDK, parce qu'elle doit observer la frontière de boucle à chaque tour (application du budget, terminaison par outil nommé, provenance par-tour, politique d'échec de dispatch).
Chaque invocation prend un SubAgentRequest portant l'id de l'entité parente (l'instance d'agent), l'AgentType, un phase kind (l'agent utilise une phase Research en findings-seulement afin qu'aucun batch de proposition de mission ne soit ouvert), la clé de site de preset (Agent:Planning / Agent:Compliance, résolue depuis l'IPlanningAgentPolicy de l'agent), le payload d'objectif, l'enveloppe de budget, les outils MCP autorisés (le toolset du mode actif), un id d'invocation et le suffixe de prompt système du mode. Il porte aussi des hooks d'attribution de coût (OwnerAgentInstanceId, QuotaWindowId) et l'override par-tour de tokens de sortie fondationnel.
Le corps de la boucle : semer les messages → tant que non terminé : vérifier le budget → résoudre le couple (model, provider, backend) depuis le preset/site (voir §3.7) → appeler le LLM via un IChatClient Microsoft.Extensions.AI → écrire un LlmCallSnapshot (étiqueté AgentPlanning, indexé par id d'invocation) → mettre à jour la consommation → dispatcher chaque FunctionCallContent via son handler MCP typé → réagir. La règle porteuse (§1.1) : l'appel d'outil est le commit ; le texte de réponse est une trace de raisonnement et n'est jamais parsé pour l'état. La terminaison se fait par outil de control-plane nommé (phase_complete / phase_blocked), non par interprétation de prose ; si un tour émet zéro appel d'outil, la boucle ajoute un coup de pouce synthétique pour terminer.
Repli & provenance. L'invoker pilote la chaîne de repli du preset — bascule de credentials intra-provider d'abord (p. ex. un OAuth-Max 429 → la clé API de priorité inférieure), puis avance de provider — émettant des spans et compteurs OpenTelemetry (subagent.llm_call.fallback_advance, …all_providers_exhausted, etc.). Chaque tentative enregistre un CallAttempt sur le snapshot, avec le modèle servi-vs-demandé et le coût calculé côté Core via le chemin de pricing canonique (l'agent n'auto-reporte jamais le coût). AgentAgent relie ensuite le dernier id de snapshot sur le tour du registre afin que le tableau de bord affiche modèle servi + coût par tour.
Les overlays font partie du preset résolu — un overlay de prompt (et les profils routing-chain / tool-policy / sampling / limits) liés au site. Le fragment de prompt du mode est ajouté au prompt système de base invariant à l'intérieur de l'invoker (base + "\n\n" + fragment), gardant la base invariante par mode.
Parité hors-processus (chemin pod). Quand EphemeralPod=true, le runner d'agent programmatique n'a pas d'invoker en-processus, alors AgentPodMcpTools porte la provenance exacte en deux étapes sur la surface d'agent : agent_record_call_attempt frappe le snapshot AgentPlanning + la tentative terminale afin que les tableaux de bord s'allument de façon identique. Le tick de pod lui-même exécute agent_tick_claim → (tour de substrat) → agent_tick_complete, le superviseur effectuant l'unique écriture de passivation côté serveur.
3.6 Budgets par-tick
AgentTickBudgetResolver est la source unique de vérité pour l'enveloppe de budget par-tick d'un agent permanent. Chaque axe lit la couche de config DB en premier (les clés AgentDispatcher:Tick* dans system_configs), avec repli sur la valeur AgentDispatcherOptions (appsettings/env, elle-même retombant sur le défaut du code). La lecture DB-d'abord est ce qui rend les plafonds ajustables en direct par l'opérateur — une écriture via l'UI Settings / config_set prend effet dès le tick suivant, sans redémarrage.
L'enveloppe (valeurs par défaut du SOA, toutes surchageables par config) :
| Axe | Défaut | Clé de config |
|---|---|---|
| Tokens max (entrée+sortie) | 24 000 | AgentDispatcher:TickMaxTokens |
| Appels d'outils max | 12 | …:TickMaxToolCalls |
| Temps mural max | 300 s | …:TickMaxWallClockSeconds |
| Coût max | 2,00 $ | …:TickMaxCostUsd |
| Tours max (anti-emballement dur) | 12 | …:TickMaxTurns |
AgentAgent.ResolveTickBudget superpose trois sources, par précédence :
- Phase fondationnelle d'abord. Tant que
is_building_starting_worksetest positionné et dans les limites deworkset_deadline_utc, chaque axe est élevé àUnlimitedet le plafond de sortie par-tour monte àFoundationalMaxOutputTokensPerTurn(par défaut 16 384) — afin qu'un agent tout neuf puisse construire son starting workset unique (son socle de connaissances / corpus de dossiers) sans bridage. L'agent termine la phase en appelant le terminateur de contrôleagent_workset_complete; la deadline (par défautnow + 24 h) l'auto-répare si le terminateur n'est jamais appelé. Le plafond hebdomadaire en dollars reste en vigueur tout du long comme filet anti-coût-emballé. L'exemption est universelle — elle vit sur la couture partagéeAgentAgent, donc elle s'applique à chaque type permanent. - Override de mode. Un mode peut porter un override
BudgetJson(p. ex. le mode ComplianceResearchélargit à 48k tokens / 24 appels d'outils / 600 s / 4,00 $ / 24 tours pour la rédaction de dossiers de chapitre entier) — appliqué uniquement à ce mode, n'élargissant jamais le défaut partagé. - Défaut de politique — le
IPlanningAgentPolicy.TickBudget(lu en direct depuis les options à chaque accès).
3.7 Résoudre le couple (model, provider)
Chaque appel LLM d'agent est attribué à un call-site dans le LlmSiteRegistry statique (chaque LlmCallSite porte un SiteId, un nom d'affichage, un modèle par défaut, les backends supportés, les outils autorisés/refusés par défaut et les axes de catalogue — catégorie, la frontière de track à laquelle il appartient, le mode de dispatch, les ids par défaut de routing-chain/tool-policy/overlay). Les sites d'agents sont Agent:Default, Agent:Planning, Agent:Compliance, PromptRedactor:Default, PromptRedactor:Redact et Agent:Pod.
Résolution au niveau de l'invoker :
- La politique de l'agent fournit la clé de site de preset (SOA →
Agent:Planning; Compliance →Agent:Compliance). - Le résolveur de provider recherche le preset attaché au site (liaison
llm_site_attach_preset). Si le site feuille n'en a aucun, il remonte la chaîne de parents (GetParentSiteId:Agent:Planning→Agent:Default;Agent:Compliance→Agent:Default;PromptRedactor:Redact→PromptRedactor:Default) et utilise la liaison par défaut de la famille (LLM.Agent:Default.Preset). Un seeder par défaut sème une liaison initiale afin que la résolution atterrisse sur un vrai preset plutôt que sur la queue héritée[synthetic, anthropic]. - Le preset résolu produit le couple concret (model, provider, backend) plus sa chaîne de repli et tous overlays/profils attachés. L'invoker essaie chaque couple dans l'ordre (bascule de credentials au sein d'un provider avant d'avancer).
Ainsi « avec quel modèle le SOA pense-t-il » se répond par : le preset lié à Agent:Planning (ou hérité de Agent:Default), épinglé en direct par un opérateur via llm_site_attach_preset — sans changement de code, effectif au tick suivant. Le défaut actuel du code pour la famille d'agents est claude-opus-4-8.
La deny-list Agent:Pod sur ce site est purement indicative (un rognage de tools/list). La frontière faisant autorité pour la surface de l'agent-pod distant est le gate de classe : AgentPodMcpTools est la seule classe qu'un token d'agent peut atteindre (il satisfait AgentPodPolicy et rien d'autre), et chaque outil dangereux (decision_respond, *_approve, config_set, mutations llm_*, constitution_*, project_delete, écritures gitlab_*, coder_spawn, modification_apply, browser_*) vit dans une classe gardée par une politique que le token d'agent échoue. La deny-list énumère chaque membre explicitement parce que le filtrage d'outils correspond aux noms exacts (pas de glob).
3.8 Le rail outils/gouvernance
L'ensemble des outils qu'un agent permanent peut jamais atteindre sur le chemin chaud est le superset d'outils §3 (AgentModeCatalog.ToolSuperset), et le toolset d'un mode est toujours un sous-ensemble strict (asserté en fail-closed). Chaque membre du superset est soit en lecture seule (soa_query_telemetry, agent_query_memory, intelligence_knowledge_query, intelligence_code_search, compliance_query_posture, compliance_knowledge_query, orchestrator_research_get, compliance_dossier_list), soit enregistrement-d'artefact (compliance_dossier_record / _translate, compliance_knowledge_ingest, orchestrator_research_create), soit un verbe d'escalade/contrôle soumis à un humain (soa_propose_decision, soa_send_message, soa_propose_mode_change, agent_workset_complete).
L'unique mutation de tenant auto-appliquante — soa_propose_task (création de mission soumise à la gouvernance) — est dans le superset pour le SOA uniquement. La deny-list SelfApplyingTools gardée par la machine assère qu'aucun toolset de mode Compliance (ou Prompt Engineer) ne le contient jamais : ces agents peuvent escalader vers un humain mais ne peuvent jamais auto-appliquer un changement. Ce rail « n'auto-applique jamais » est imposé en code (un test assère que le superset ne contient aucun outil auto-appliquant au-delà de celui du SOA, et que les modes Compliance l'excluent), pas seulement documenté.
Les handlers d'action eux-mêmes gardent leurs gates de gouvernance intacts quelle que soit la surface (chaud en-processus ou pod) : soa_propose_task exécute les vérifications constitution + boundary avant de persister ; soa_propose_decision / soa_send_message / soa_propose_mode_change exécutent la constitution sur leur contenu. Une violation est bloquée-mais-réinjectée (renvoyée au modèle comme un { blockedReason, violations } structuré, jamais levée en exception), afin que l'agent puisse reconsidérer dans le même épisode.
3.9 Cycle de vie & contrôle
AgentControlService possède chaque action opérateur et chaque mise en file (l'agent ne met jamais en file) :
- Start / Stop / Reset (SOA) et StartCompliance / StopCompliance — bascule
statusActive↔Paused, semant un événementAgentInitializedau start. Reset efface en plus la mémoire du registre, draine les événements périmés et re-sème une ouverture fraîche (refusant si un tick est en cours, pour protéger l'invariant d'unique-écrivain-de-passivation). - Chat opérateur — persiste le message (transcript durable), met en file un événement
OperatorMessage, diffuse en direct. - Contrôle de mode — l'ensemble UI (
source=ui) et le point d'étranglement d'approbation (source=approval), chacun validant la cible contre le catalogue propre de l'agent et auditant le changement. - Aller-retour d'escalade — à la résolution par un opérateur d'une AttentionRequest d'origine SOA, appliquer tout changement de mode approuvé, puis mettre en file la reprise
OperatorAnsweredEscalation(idempotente via l'index de dédup-de-reprise). - Signaux Prompt Engineer —
RaiseRedactorSignalAsyncassure-crée l'instance de redactorPausedliée au call-site et met en file un événementExternalSignalReceived(livré éteint — durablement en file mais non tické tant que le corps du redactor n'est pas activé).
Le registre d'agents (AgentRegistryService) est l'annuaire de coordination en mémoire distinct : partitionné par tenant, thread-safe, expiré par heartbeat (5 min), faisant surface des agents de registre (y compris l'auto-enregistrement du SOA) sur /agents. Il est éphémère — re-peuplé après un redémarrage de pod — et strictement isolé par tenant.
4. Mise en perspective : la vie d'un agent permanent
- Bootstrap. Le dispatcher assure un SOA par tenant. Avec l'inactivité-par-défaut activée, il est créé
Active, entre dans la phase fondationnelle (par-tick sans bridage, plafond hebdomadaire en $ toujours imposé) et est seméAgentInitialized. - Ouverture. Le premier tick survole le contexte et poste un court salut ancré (
soa_send_message), puis planifie le prochain heartbeat ~24 h plus tard et se tait. - Fondation. Au fil des ticks suivants (sans bridage) il construit son starting workset, puis appelle
agent_workset_completepour revenir au budget par-tick normal (ou la deadline l'auto-répare). - Régime permanent. Il reste inactif sur le heartbeat quotidien fixe. À chaque heartbeat : sonder la santé d'ops, hacher les entrées externes, et court-circuiter sans appel LLM si rien n'a changé.
- Réagir. Un vrai événement — un message opérateur, une escalade répondue, un changement d'état de mission, un incident basculant le bucket d'ops — est mis en file avec
visible_at = now, réveille l'agent immédiatement et force une re-planification. Dans ce tick l'invoker exécute un épisode borné : interroger la mémoire/télémétrie pour s'ancrer, puis prendre au plus une ou deux actions gouvernées (proposer une mission, escalader une décision, envoyer un message), sans jamais dupliquer quelque chose déjà présent dans l'effectif des éléments ouverts, puisphase_complete. - Gate humain. Tout véritable embranchement devient une AttentionRequest sur la file de décisions ; la réponse de l'opérateur revient comme
OperatorAnsweredEscalationet l'agent reprend. - Modes. Un humain (UI ou approbation) peut changer le focus de l'agent —
Strategic/Incident/Discovery/… pour le SOA,Cadence/GateReview/Researchpour Compliance — resserrant ou élargissant sa surface d'outils et son budget par épisode. L'agent ne peut que proposer un changement. - Sûreté. Si un agent en chemin-pod se coince (
MaxConsecutiveBlockedTurnsblocages consécutifs) il s'arrête àClosed, draine sa file (KEDA scale à zéro) et fait surface d'une AttentionRequest opérateur.