Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 KiB
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 à jourARCHITECTURE.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
OrchestratorCommanddéjà existant. La voie principale du retour de valeur reste §17 (send_blocking) ; MCP ne fait que déclencherdispatch, 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<McpCapability> sur AgentProfile,
exactement comme session: Option<SessionStrategy> et structured_adapter: Option<StructuredAdapter>
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_*.
// 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) :
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
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êmeOrchestratorCommand. Aucun agent n'est jamais bloqué : sans MCP, la prose# Orchestration IdeA+.ideai/requestsreste 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_agentcible 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'unaskdoit ê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 outilsidea_ask_agent/idea_launch_agent/idea_list_agents(au lieu d'« écris un JSON dans.ideai/requests»). - agent fichier (
mcp == None) : prose.ideai/requestsactuelle, 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 <run_dir>/...
apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env
ConfigFile { target }: écrit<run_dir>/<target>(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 auSpawnSpec.args.Env { var }: ajoute la variable auSpawnSpec.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) enOrchestratorCommand, appelledispatch, et renvoieOrchestratorOutcome(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_agentsréutiliseListAgents. La validation (OrchestratorRequest::validate) reste le point unique « parse, don't validate » — l'adapter MCP construit unOrchestratorCommand(directement, ou viaOrchestratorRequestpour réutiliser la validation, au choix d'implémentation). - Réutilisation de
OrchestratorServiceplutôt que duplication : le serveur MCP reçoitArc<OrchestratorService>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
idde message ⇒ rien à modéliser côté IdeA. - Fichier :
<file>.json→<file>.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/requestsrestent 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 resteOrchestratorCommand).
| 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<McpCapability> (+ 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<OrchestratorService> 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"`) |
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/McpConfigStrategysont 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 queOrchestratorCommand/dispatch). - S : le serveur MCP = une seule techno d'entrée (MCP→commande).
OrchestratorServicegarde sa responsabilité (commande→use cases).LaunchAgentgagne une étape d'injection homogène, pas une responsabilité nouvelle. - O : ajouter une CLI MCP = un bloc
mcpsur 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<OrchestratorService>(pas les use cases en détail). - D : tout injecté au composition root (
state.rs) ; aucunnewd'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_idpersisté sur la cellule). Interaction avec v3 : à la reprise,LaunchAgentré-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
- Capacité MCP =
Option<McpCapability>sur le profil (Open/Closed,None⇒ repli fichier+prose, zéro régression sérialisation). - Retour synchrone d'
ask: RIEN de neuf — réutilisesend_blocking/AskAgent/AgentReplied/OrchestratorOutcome.replydéjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas deCorrelationId. Le transport MCP corrèle nativement. - Interdiction subagents natifs conservée + alternative native : prose adaptée selon surface ; conf MCP injectée par CLI au
LaunchAgentdans le run dir isolé (McpConfigStrategy: ConfigFile/Flag/Env), symétrique au convention file et au seed permissions. - Frontières : serveur MCP = adapter entrant infra (
infrastructure/src/orchestrator/mcp/), pair du watcher fichier, appelant le mêmeOrchestratorService::dispatch. Aucun nouveau port applicatif/domaine. - Lots : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel).