# Cadrage Architecture — Orchestration v3 : invocation native d'agents (surface MCP) > Produit par **Architect** en réponse au brief `orchestration-v3-invocation-native.md`. > Cadrage **avant tout code** (méthode §3). Livrable : ce document + mise à jour `ARCHITECTURE.md` §14.3. > Aucun code de production ici. --- ## 0. État réel du terrain — ce qui est DÉJÀ résolu (lu dans le code, pas présumé) Le brief décrit trois faiblesses (conscience = prose, pas de discussion inter-agents, fire-and-forget). **Deux des trois sont déjà comblées par le pivot §17** (livré, lots D0→D7). Il faut le constater honnêtement pour ne **pas re-cadrer** ce qui existe : | Faiblesse du brief | Statut réel | Référence code | |---|---|---| | Pas de discussion inter-agents (`agent.message` « future », `task` ignoré pour agent vivant) | ✅ **RÉSOLU** | `OrchestratorCommand::AskAgent` (`domain/src/orchestrator.rs`), `OrchestratorService::ask_agent` (`application/src/orchestrator/service.rs`) | | Fire-and-forget, pas de corrélation requête↔réponse | ✅ **RÉSOLU sans outbox** : le rendez-vous synchrone est **intrinsèque** à `AgentSession::send()` (flux → `Final` déterministe). Le `Final` *est* la fin de tour. | `application/src/agent/structured.rs` (`send_blocking`), `domain/src/ports.rs` (`ReplyEvent::Final`) | | Pas de réveil du demandeur / event de réponse | ✅ **RÉSOLU** | `DomainEvent::AgentReplied`, `OrchestratorResponse.reply` (`infrastructure/src/orchestrator/mod.rs`) | | Conscience = soft prompt (l'agent doit deviner le schéma JSON) | ⚠️ **PARTIEL** : la prose `# Orchestration IdeA` est injectée (`compose_convention_file`), mais **aucun outil typé natif** n'est exposé. | `application/src/agent/lifecycle.rs` | | Interdiction des subagents natifs **+** alternative native | ⚠️ **PARTIEL** : interdiction présente (prose) ; l'alternative native (outils `idea_*`) **manque encore**. | idem | | Capacité MCP sur le profil | ❌ **ABSENT** | — | | Serveur MCP / config MCP par CLI | ❌ **ABSENT** | — | **Conclusion de cadrage** : l'orchestration v3 **n'est plus** « combler la messagerie inter-agents » (c'est fait). Elle se réduit à **un seul chantier net** : **exposer l'orchestration IdeA comme serveur MCP model-agnostic**, en tant qu'**adapter entrant supplémentaire** par-dessus le **même** `OrchestratorService::dispatch`, avec **repli homogène** sur le protocole fichier `.ideai/requests` (§14.3) + prose (`compose_convention_file`) pour les CLI sans MCP. C'est ce que cadre la suite. > **Principe directeur (zéro régression, §9/§17.3)** : MCP est un **confort de conscience native** > (outils typés, plus de schéma à deviner). Il **n'invente aucune sémantique** : tout outil MCP se > ramène à un `OrchestratorCommand` déjà existant. La voie principale du *retour de valeur* reste > §17 (`send_blocking`) ; MCP ne fait que **déclencher** `dispatch`, jamais re-router la réponse. --- ## 1. Décisions tranchées (les 4 points durs du brief) ### Décision 1 — Capacité MCP par runtime = champ optionnel `mcp` sur `AgentProfile` (Open/Closed) **Tranché** : on ajoute un champ **optionnel** `mcp: Option` sur `AgentProfile`, exactement comme `session: Option` et `structured_adapter: Option` le sont déjà. `None` (défaut) ⇒ **repli fichier + prose** (comportement actuel, zéro régression). `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et l'agent voit les outils `idea_*`. ```rust // domain/src/profile.rs — capacité MCP déclarative (pur, validé par constructeur, comme SessionStrategy) /// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI /// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code, /// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "strategy")] pub enum McpConfigStrategy { /// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1) /// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`). ConfigFile { target: String }, // relative_safe(target) — pas de `..`, pas d'absolu /// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`). Flag { flag: String }, // non_empty(flag) /// Passer via une variable d'environnement. Env { var: String }, // valid_env_var(var) } /// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, /// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + prose. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpCapability { /// Comment matérialiser la config MCP au lancement (relatif au run dir). pub config: McpConfigStrategy, /// Transport du serveur MCP IdeA (détail invisible au domaine ; voir D3). /// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert). #[serde(default)] pub transport: McpTransport, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum McpTransport { #[default] Stdio, Socket } ``` Sur `AgentProfile`, additif et **non cassant** (sérialisation inchangée pour les profils sans MCP) : ```rust #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, ``` Builder additif (comme `with_structured_adapter`) : `AgentProfile::new(...).with_mcp(cap)` ; la signature de `AgentProfile::new` reste **inchangée** ⇒ tous les appels du catalogue/tests restent verts. **Justification** : cohérence §9 (« ajouter une IA = donnée, pas code »), symétrie avec les deux autres capacités optionnelles déjà sur le profil, `skip_serializing_if = None` ⇒ **zéro régression** de sérialisation. Le **prédicat de surface** est `profile.mcp.is_some()` — un seul point de vérité. > **Modèle en couches (exigé par le brief §4.1)** : la surface effective d'un agent est > `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }`. Les deux couches > produisent le **même** `OrchestratorCommand`. Aucun agent n'est jamais bloqué : sans MCP, la prose > `# Orchestration IdeA` + `.ideai/requests` reste pleinement fonctionnelle. --- ### Décision 2 — Retour synchrone d'`ask` = AUCUN nouveau modèle de corrélation : on réutilise `send_blocking` **Tranché (et c'est la décision la plus importante)** : l'outil MCP `idea_ask_agent` **ne crée aucune corrélation requête↔réponse, aucun outbox, aucun event de réveil neufs**. Il appelle le **même** `OrchestratorService::dispatch(AskAgent { target, task })` que le watcher fichier, qui **retourne déjà** `OrchestratorOutcome { reply: Some(content) }` via `send_blocking`. L'adapter MCP **renvoie ce `content` inline** comme valeur de retour de l'outil. Fin. Le brief (rédigé avant le pivot §17) supposait qu'`ask` était un point dur à résoudre via outbox + corrélation fichier. **Le pivot §17 l'a déjà tranché autrement** : le `Final` du `ReplyStream` *est* la fin de tour déterministe ; pas besoin de deviner, pas d'outbox, pas de `request_id`. On **n'y revient pas**. Le tableau ci-dessous fige la sémantique, déjà implémentée : | Aspect | Décision (déjà en place) | Code | |---|---|---| | **Corrélation** | Aucune : `dispatch` est un appel synchrone `async` ; la réponse est la valeur de retour. Le transport MCP (JSON-RPC) porte nativement la corrélation requête/réponse. | `service.rs::ask_agent` | | **Outbox** | **Supprimé de la voie principale** (§17.4). Pas réintroduit. | — | | **Event `AgentReplied`** | **Observabilité UI uniquement** (« Architect a répondu à Main »), best-effort, ne porte pas la valeur. | `reply_outcome` | | **Timeout** | Borné (`ASK_AGENT_TIMEOUT = 300 s`). À l'expiration : `AgentSessionError::Timeout` → la cible **reste vivante** (non tuée), l'outil MCP renvoie une **erreur typée** ; l'appelant décide. | `send_blocking` | | **Cible a déjà une session vivante** (one-live-session-per-agent) | `ask` **réutilise** la session structurée vivante (`session_for_agent`) — rendez-vous direct, pas de respawn. Si la cible est vivante en **PTY brut** (profil sans `structured_adapter`) ⇒ erreur typée explicite (**jamais** un ACK trompeur). | `service.rs::ask_agent` étapes 1→3 | | **Cible morte** | `LaunchAgent` en mode structuré (background) puis `send_blocking`. Garde d'unicité sur **les deux** registres. | idem | **Justification** : DRY radical (une seule logique de rendez-vous, partagée par UI chat, watcher fichier et MCP) ; frontière nette (le domaine ne connaît qu'un `prompt` et un `Final`, jamais un transcript ni un id de corrélation) ; universalité (marche pour toute CLI structurée Claude/Codex). **Le seul travail v3 ici est de brancher l'outil MCP sur `dispatch` — pas de re-cadrer le rendez-vous.** > **Conséquence produit** : `idea_ask_agent` cible **toujours un agent structuré** (Claude/Codex), > cohérent avec le menu restreint §17.3/§17.6. Un agent **demandeur** peut être n'importe quelle CLI > MCP (Claude, Codex, Gemini…) ; un agent **cible** d'un `ask` doit être structuré. C'est déjà > l'invariant en vigueur — MCP ne le change pas. --- ### Décision 3 — MCP vs subagents natifs : on garde l'interdiction ET on offre l'alternative native, config injectée par CLI au lancement **Tranché** : l'interdiction des subagents natifs (prose `# Orchestration IdeA`) **reste** — elle protège l'identité/mémoire/observabilité IdeA. Mais on offre désormais la **vraie alternative native** : les outils `idea_*` apparaissent dans la liste d'outils de la CLI. La prose est **adaptée selon la surface** : - agent **MCP** (`profile.mcp.is_some()`) : la prose pointe vers les outils `idea_ask_agent` / `idea_launch_agent` / `idea_list_agents` (au lieu d'« écris un JSON dans `.ideai/requests` »). - agent **fichier** (`mcp == None`) : prose `.ideai/requests` actuelle, **inchangée**. **Injection de la config MCP par CLI = au `LaunchAgent`, dans le run dir isolé (§14.1), via la `McpConfigStrategy`** — exactement le même point et la même mécanique que le convention file : ``` LaunchAgent::execute (après apply_injection, avant spawn/factory.start) : if let Some(mcp) = &profile.mcp: // IdeA matérialise SA config MCP au format de CETTE CLI dans /... apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env ``` - `ConfigFile { target }` : écrit `/` (ex. `.mcp.json`) avec la déclaration du serveur MCP IdeA (commande/transport). Non-clobbering, best-effort, **comme le seed de permissions**. - `Flag { flag }` : ajoute le flag + chemin au `SpawnSpec.args`. - `Env { var }` : ajoute la variable au `SpawnSpec.env`. Le **serveur MCP lui-même** est démarré **par projet ouvert**, à côté du `FsOrchestratorWatcher`, dans le **même hook** `ensure_orchestrator_watch` (`app-tauri/src/state.rs`). Une CLI qui se lance avec la config injectée se connecte à ce serveur (stdio : IdeA spawn un pont par session ; socket : adresse partagée — détail d'adapter, point ouvert S-MCP). **Justification** : symétrie totale avec le convention file et le seed de permissions (même run dir, même best-effort non-clobbering, même moment) ⇒ aucune nouvelle plomberie de cycle de vie. La config MCP est **donnée déclarative par profil**, donc « ajouter une CLI MCP = donnée, pas code ». --- ### Décision 4 — Frontières hexagonales : le serveur MCP est un adapter entrant d'infrastructure ; AUCUN nouveau port applicatif **Tranché** : le serveur MCP est un **driving adapter d'infrastructure** (`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`. Il appelle le **même** `OrchestratorService::dispatch` (application) et **ne duplique rien**. Trois portes d'entrée substituables se ramènent au même `OrchestratorCommand` : ``` ┌─────────────────────────────────────────────┐ Agent MCP ───▶│ Serveur MCP (infra/orchestrator/mcp) │──┐ └─────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────┐ │ OrchestratorCommand Fichier ───▶│ FsOrchestratorWatcher (infra/orchestrator) │──┼──▶ OrchestratorService::dispatch (.ideai/ └─────────────────────────────────────────────┘ │ (application — INCHANGÉ) requests) ┌─────────────────────────────────────────────┐ │ │ UI ───▶│ Commandes Tauri (app-tauri) │──┘ ▼ └─────────────────────────────────────────────┘ use cases agent/terminal ``` - **Où vit le serveur MCP** : `infrastructure/src/orchestrator/mcp/`. Il **traduit** un appel d'outil MCP (`idea_ask_agent`, `idea_launch_agent`, `idea_list_agents`, et par parité `idea_update_context`, `idea_create_skill`, `idea_stop_agent`) en `OrchestratorCommand`, appelle `dispatch`, et renvoie `OrchestratorOutcome` (`reply`/`detail`) inline comme résultat d'outil. JSON-RPC, stdio/socket, le crate MCP : **tout reste dans cet adapter**. Le domaine/application ignorent MCP. - **Quel port côté domaine/application** : **aucun nouveau**. `OrchestratorService::dispatch` (application) est déjà l'unique seam. `idea_list_agents` réutilise `ListAgents`. La validation (`OrchestratorRequest::validate`) reste le point unique « parse, don't validate » — l'adapter MCP construit un `OrchestratorCommand` (directement, ou via `OrchestratorRequest` pour réutiliser la validation, au choix d'implémentation). - **Réutilisation de `OrchestratorService` plutôt que duplication** : le serveur MCP reçoit `Arc` au composition root (`state.rs`), exactement comme le watcher. Une seule logique applicative ; les adapters ne portent que leur techno d'entrée. **Justification** : DRY + règle de dépendance hexagonale. Cible, identité, mémoire, observabilité UI passent **toujours** par le seul chemin applicatif. Les spikes MCP (transport, crate) sont **confinés** à l'adapter infra et ne touchent ni le domaine ni l'application. --- ## 2. Modèle de messages corrélés — état figé (rien de neuf) La « corrélation requête↔réponse » du brief est portée **nativement par le transport** : - **MCP** : JSON-RPC corrèle requête/réponse par `id` de message ⇒ rien à modéliser côté IdeA. - **Fichier** : `.json` → `.json.response.json` (sibling), déjà en place. - **Valeur de retour** : `OrchestratorOutcome { detail, reply }` (application) → `OrchestratorResponse { ok, action, detail, error, reply }` (infra fichier) **ou** résultat d'outil MCP. **Structs déjà définies**, réutilisées telles quelles. Aucun `CorrelationId`, aucun `AgentReply`, aucun port `AgentReplyChannel`, aucun outbox : **abandonnés par le pivot §17** et **non réintroduits** par v3. C'est la simplification clé. --- ## 3. Découpage en LOTS testables (méthode §3) — MCP uniquement > Chaque lot = binôme dev+test, vert avant le suivant. Backend/frontend séparés. > Les terminaux non-IA et le chemin fichier `.ideai/requests` restent verts à chaque lot. > **Spike S-MCP** (crate MCP Rust + transport stdio/socket + format de conf par CLI) est **confiné au > lot M2** et n'invalide pas l'ossature (le contrat d'entrée reste `OrchestratorCommand`). | Lot | Côté | Périmètre | Crates/dossiers | Contrats | Tests attendus | |---|---|---|---|---|---| | **M0 (capacité profil)** | back | `McpCapability` + `McpConfigStrategy` + `McpTransport` (domaine, validés) ; champ `AgentProfile.mcp: Option` (+ builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés (ex. `ConfigFile { target: ".mcp.json" }`). | `domain/src/profile.rs`, `application/src/agent/catalogue.rs` | enum + struct + champ optionnel sérialisé | unit purs : `mcp = None` round-trip **identique à avant** (zéro régression sérialisation) ; `Some(_)` round-trip ; constructeurs valident (`relative_safe` target, `non_empty` flag, `valid_env_var`) ; catalogue annoté. | | **M1 (injection conf MCP au lancement)** | back | `LaunchAgent` matérialise la conf MCP selon `McpConfigStrategy` dans le run dir isolé (après `apply_injection`, avant spawn/`factory.start`) : `ConfigFile`→write non-clobbering, `Flag`→`args`, `Env`→`env`. Prose `compose_convention_file` adaptée selon `mcp.is_some()`. | `application/src/agent/lifecycle.rs` | `LaunchAgent` (chemin MCP additif) | unit (fakes) : profil `mcp=None` ⇒ **aucun** write/flag/env MCP (chemin actuel inchangé) ; `ConfigFile` ⇒ fichier écrit au bon chemin, non-clobbering ; `Flag`/`Env` ⇒ `spec` enrichi ; prose contient les outils `idea_*` si MCP, sinon `.ideai/requests`. | | **M2 (serveur/adapter MCP)** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` (+ parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) → `OrchestratorCommand` → `dispatch` → résultat inline. **Spike S-MCP** (crate, transport) isolé ici. | `infrastructure/src/orchestrator/mcp/` | mapping outil→commande ; `Arc` injecté | unit (fakes + `OrchestratorService` à use cases fakes) : chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout → erreur typée, cible non tuée ; `idea_list_agents` liste ; JSON-RPC malformé → erreur, jamais panic. Hors-réseau (transport en mémoire/pipe scriptable). | | **M3 (câblage par projet)** | back | Démarrer le serveur MCP par projet ouvert dans `ensure_orchestrator_watch` (à côté du watcher) ; registre `mcp_servers` jumeau de `orchestrator_watchers` ; arrêt à la fermeture du projet. | `app-tauri/src/state.rs`, `commands.rs` | hook `ensure_orchestrator_watch` étendu | app-tauri : un serveur MCP par projet, idempotent ; fermeture du projet ⇒ arrêt ; coexiste avec le watcher fichier (les deux portes vivantes). | | **M4 (observabilité UI — optionnel)** | front | Surfacer dans l'UI Agents qu'une délégation est passée par MCP vs fichier (badge/source sur l'event `OrchestratorRequestProcessed` / `AgentReplied`). Non bloquant. | `frontend/src/features/agents` | DTO d'event enrichi (`source: "mcp"|"file"`) | Vitest : badge source affiché ; absence d'event ⇒ pas de régression. | **Ordre conseillé** : **M0 → M1 → M2 → M3** (→ M4 optionnel). M0 débloque tout (donnée pure) ; M1 injecte la conf (testable sans serveur) ; M2 livre l'adapter derrière un transport scriptable (spike confiné) ; M3 le câble par projet. M4 est du confort d'observabilité. --- ## 4. Conformité hexagonale & SOLID (rappel) - **Règle de dépendance** : `McpCapability`/`McpConfigStrategy` sont **domaine** (purs, validés). Le serveur MCP, JSON-RPC, stdio/socket, le crate MCP sont **exclusivement** infra. Le domaine et l'application **ignorent** MCP (l'application ne voit que `OrchestratorCommand`/`dispatch`). - **S** : le serveur MCP = une seule techno d'entrée (MCP→commande). `OrchestratorService` garde sa responsabilité (commande→use cases). `LaunchAgent` gagne une étape d'injection homogène, pas une responsabilité nouvelle. - **O** : ajouter une CLI MCP = un bloc `mcp` sur le profil (**donnée**). Aucun cœur touché. - **L** : les trois portes d'entrée (fichier, MCP, UI) sont substituables — même `dispatch`, même résultat. Repli fichier ≡ MCP du point de vue de la réponse. - **I** : le serveur MCP ne reçoit que `Arc` (pas les use cases en détail). - **D** : tout injecté au composition root (`state.rs`) ; aucun `new` d'adapter MCP ailleurs. --- ## 5. Chantiers adjacents (situés, NON cadrés ici) - **Hot-swap de l'AI profile** (chantier A, §15.1) : **LIVRÉ** (`ChangeAgentProfile`). Interaction avec v3 : un swap vers/depuis un profil MCP change la surface (`mcp.is_some()`) ⇒ au relance, `LaunchAgent` (ré)injecte ou retire la conf MCP automatiquement. **Rien à cadrer** : la surface suit le profil courant, point de vérité unique. - **Reprise auto des sessions au redémarrage** (chantier B, §15.2) : **LIVRÉ** (`ListResumableAgents`, `conversation_id` persisté sur la cellule). Interaction avec v3 : à la reprise, `LaunchAgent` ré-matérialise la conf MCP comme à tout lancement (M1). **Rien à cadrer**. Ces deux chantiers **ne sont pas un prérequis** de v3/MCP et n'en bloquent aucun lot. --- ## 6. Synthèse des décisions 1. **Capacité MCP = `Option` sur le profil** (Open/Closed, `None` ⇒ repli fichier+prose, zéro régression sérialisation). 2. **Retour synchrone d'`ask` : RIEN de neuf** — réutilise `send_blocking`/`AskAgent`/`AgentReplied`/`OrchestratorOutcome.reply` déjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas de `CorrelationId`. Le transport MCP corrèle nativement. 3. **Interdiction subagents natifs conservée + alternative native** : prose adaptée selon surface ; conf MCP injectée **par CLI** au `LaunchAgent` dans le run dir isolé (`McpConfigStrategy` : ConfigFile/Flag/Env), symétrique au convention file et au seed permissions. 4. **Frontières** : serveur MCP = **adapter entrant infra** (`infrastructure/src/orchestrator/mcp/`), pair du watcher fichier, appelant le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** applicatif/domaine. 5. **Lots** : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel).