feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,16 +19,25 @@ use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::ports::{EventBus, ProfileStore};
|
||||
use domain::conversation::{
|
||||
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
|
||||
};
|
||||
use domain::input::InputMediator;
|
||||
use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{EventBus, ProfileStore, PtyHandle};
|
||||
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
|
||||
use crate::agent::{
|
||||
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
@ -61,6 +70,19 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// pleins d'attente.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
|
||||
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
|
||||
/// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint).
|
||||
///
|
||||
/// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin
|
||||
/// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5
|
||||
/// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière.
|
||||
pub trait McpRuntimeProvider: Send + Sync {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply).
|
||||
/// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement).
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime>;
|
||||
}
|
||||
|
||||
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
|
||||
pub struct OrchestratorService {
|
||||
create_agent: Arc<CreateAgentFromScratch>,
|
||||
@ -71,11 +93,27 @@ pub struct OrchestratorService {
|
||||
create_skill: Arc<CreateSkill>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
/// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message`
|
||||
/// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via
|
||||
/// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les
|
||||
/// call sites/tests legacy qui n'utilisent pas la messagerie restent verts).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
/// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
|
||||
/// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
|
||||
/// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒
|
||||
/// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la
|
||||
/// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via
|
||||
/// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call
|
||||
/// sites/tests legacy restent verts).
|
||||
input: Option<Arc<dyn InputMediator>>,
|
||||
/// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head`
|
||||
/// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que
|
||||
/// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`].
|
||||
mailbox: Option<Arc<dyn domain::mailbox::AgentMailbox>>,
|
||||
/// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement
|
||||
/// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes.
|
||||
/// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage
|
||||
/// par agent sans matérialisation de fil (legacy).
|
||||
conversations: Option<Arc<dyn ConversationRegistry>>,
|
||||
/// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un
|
||||
/// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à
|
||||
/// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock.
|
||||
wait_for: StdMutex<WaitForGraph>,
|
||||
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un
|
||||
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
|
||||
/// publication (l'`ask` fonctionne quand même).
|
||||
@ -99,6 +137,30 @@ pub struct OrchestratorService {
|
||||
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
|
||||
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
|
||||
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
|
||||
/// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration
|
||||
/// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask`
|
||||
/// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis
|
||||
/// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`),
|
||||
/// donc zéro régression pour les call sites/tests qui ne le branchent pas.
|
||||
mcp_runtime_provider: Option<Arc<dyn McpRuntimeProvider>>,
|
||||
/// FileGuard-mediated context/memory use cases (cadrage C7). Injected via
|
||||
/// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*`
|
||||
/// renvoient une erreur typée (call sites/tests legacy restent verts).
|
||||
context_guard: Option<Arc<ContextGuardUseCases>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
/// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la
|
||||
/// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`).
|
||||
pub struct ContextGuardUseCases {
|
||||
/// Lecture d'un contexte `.md` IdeA sous read-lease.
|
||||
pub read_context: Arc<ReadContext>,
|
||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||
pub propose_context: Arc<ProposeContext>,
|
||||
/// Lecture mémoire sous read-lease.
|
||||
pub read_memory: Arc<ReadMemory>,
|
||||
/// Écriture mémoire sous write-lease.
|
||||
pub write_memory: Arc<WriteMemory>,
|
||||
}
|
||||
|
||||
/// Outcome of dispatching a command — a short, human-readable success summary the
|
||||
@ -137,12 +199,26 @@ impl OrchestratorService {
|
||||
create_skill,
|
||||
profiles,
|
||||
sessions,
|
||||
structured: None,
|
||||
input: None,
|
||||
mailbox: None,
|
||||
conversations: None,
|
||||
wait_for: StdMutex::new(WaitForGraph::new()),
|
||||
events: None,
|
||||
ask_locks: StdMutex::new(HashMap::new()),
|
||||
mcp_runtime_provider: None,
|
||||
context_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
|
||||
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
|
||||
/// inchangée).
|
||||
#[must_use]
|
||||
pub fn with_context_guard(mut self, guard: Arc<ContextGuardUseCases>) -> Self {
|
||||
self.context_guard = Some(guard);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the per-agent **turn lock**, creating it on first use.
|
||||
///
|
||||
/// Get-or-create under the synchronous map mutex (held only for this lookup,
|
||||
@ -157,13 +233,30 @@ impl OrchestratorService {
|
||||
Arc::clone(locks.entry(*agent_id).or_default())
|
||||
}
|
||||
|
||||
/// Branche le registre des sessions **structurées** (§17.5) pour servir
|
||||
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 :
|
||||
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent
|
||||
/// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`.
|
||||
/// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir
|
||||
/// `agent.message`/[`OrchestratorCommand::AskAgent`] et
|
||||
/// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de
|
||||
/// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que
|
||||
/// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/
|
||||
/// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de
|
||||
/// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts).
|
||||
#[must_use]
|
||||
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
|
||||
self.structured = Some(structured);
|
||||
pub fn with_input_mediator(
|
||||
mut self,
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
) -> Self {
|
||||
self.input = Some(input);
|
||||
self.mailbox = Some(mailbox);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre
|
||||
/// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes.
|
||||
/// Builder additif (signature de [`Self::new`] inchangée).
|
||||
#[must_use]
|
||||
pub fn with_conversations(mut self, conversations: Arc<dyn ConversationRegistry>) -> Self {
|
||||
self.conversations = Some(conversations);
|
||||
self
|
||||
}
|
||||
|
||||
@ -175,6 +268,17 @@ impl OrchestratorService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements
|
||||
/// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle**
|
||||
/// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP
|
||||
/// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout).
|
||||
/// Builder additif : signature de [`Self::new`] **inchangée**.
|
||||
#[must_use]
|
||||
pub fn with_mcp_runtime_provider(mut self, provider: Arc<dyn McpRuntimeProvider>) -> Self {
|
||||
self.mcp_runtime_provider = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// Dispatches a validated command against `project`.
|
||||
///
|
||||
/// # Errors
|
||||
@ -196,9 +300,16 @@ impl OrchestratorService {
|
||||
self.spawn_agent(project, name, profile, context, visibility)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::AskAgent { target, task } => {
|
||||
self.ask_agent(project, target, task).await
|
||||
}
|
||||
OrchestratorCommand::AskAgent {
|
||||
target,
|
||||
task,
|
||||
requester,
|
||||
} => self.ask_agent(project, target, task, requester).await,
|
||||
OrchestratorCommand::Reply {
|
||||
from,
|
||||
ticket,
|
||||
result,
|
||||
} => self.reply(from, ticket, result),
|
||||
OrchestratorCommand::ListAgents => self.list_agents(project).await,
|
||||
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
|
||||
OrchestratorCommand::UpdateAgentContext { name, context } => {
|
||||
@ -209,9 +320,135 @@ impl OrchestratorService {
|
||||
content,
|
||||
scope,
|
||||
} => self.create_skill(project, name, content, scope).await,
|
||||
OrchestratorCommand::ReadContext { target, requester } => {
|
||||
self.read_context(project, target, requester).await
|
||||
}
|
||||
OrchestratorCommand::ProposeContext {
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} => self.propose_context(project, target, content, requester).await,
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
OrchestratorCommand::WriteMemory {
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} => self.write_memory(project, slug, content, requester).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the injected C7 use cases, or a typed error when unwired.
|
||||
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
|
||||
self.context_guard.as_deref().ok_or_else(|| {
|
||||
AppError::Invalid("FileGuard context/memory tools are not configured".to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.read` → reads an IdeA-owned context under a shared read-lease; the
|
||||
/// body is returned inline in the outcome's `reply`.
|
||||
async fn read_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
target: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let md = self
|
||||
.require_context_guard()?
|
||||
.read_context
|
||||
.execute(ReadContextInput {
|
||||
project: project.clone(),
|
||||
target: target.clone(),
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!(
|
||||
"read {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
reply: Some(md.into_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.propose` → direct write (agent ctx / orchestrator on global) or a
|
||||
/// materialised proposal (non-orchestrator on global).
|
||||
async fn propose_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
target: Option<String>,
|
||||
content: String,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let outcome = self
|
||||
.require_context_guard()?
|
||||
.propose_context
|
||||
.execute(ProposeContextInput {
|
||||
project: project.clone(),
|
||||
target: target.clone(),
|
||||
content,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
let detail = match outcome {
|
||||
ProposeOutcome::Written => format!(
|
||||
"wrote {} context",
|
||||
target.as_deref().unwrap_or("project")
|
||||
),
|
||||
ProposeOutcome::Proposed { path } => {
|
||||
format!("filed proposal for project context at {path}")
|
||||
}
|
||||
};
|
||||
Ok(OrchestratorOutcome { detail, reply: None })
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
/// content is returned inline in the outcome's `reply`.
|
||||
async fn read_memory(
|
||||
&self,
|
||||
project: &Project,
|
||||
slug: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let content = self
|
||||
.require_context_guard()?
|
||||
.read_memory
|
||||
.execute(ReadMemoryInput {
|
||||
project: project.clone(),
|
||||
slug: slug.clone(),
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read memory {}", slug.as_deref().unwrap_or("index")),
|
||||
reply: Some(content),
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.write` → writes a note under an exclusive write-lease.
|
||||
async fn write_memory(
|
||||
&self,
|
||||
project: &Project,
|
||||
slug: String,
|
||||
content: String,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
self.require_context_guard()?
|
||||
.write_memory
|
||||
.execute(WriteMemoryInput {
|
||||
project: project.clone(),
|
||||
slug: slug.clone(),
|
||||
content,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("wrote memory {slug}"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `spawn_agent`: create the agent if the manifest doesn't already hold one by
|
||||
/// that name, then launch it (which publishes `AgentLaunched` → the UI opens a
|
||||
/// cell + the Agents tab).
|
||||
@ -328,88 +565,346 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4).
|
||||
/// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous**
|
||||
/// (Option 1 « Terminal + MCP », lot B-3).
|
||||
///
|
||||
/// Resolves the target by name, ensures it has a **live structured session**
|
||||
/// (launching it in structured mode if needed), sends `task` and **waits for the
|
||||
/// turn's `Final`** via [`send_blocking`], then returns its content as
|
||||
/// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`].
|
||||
/// The target's human-facing view is now a **raw native terminal** (PTY REPL), and
|
||||
/// delegation flows through the terminal's single FIFO input plus the MCP mailbox:
|
||||
///
|
||||
/// Invariants:
|
||||
/// - **1 session vivante/agent** : on réutilise la session structurée vivante si
|
||||
/// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée.
|
||||
/// - **Timeout ne tue pas la session** : [`send_blocking`] remonte
|
||||
/// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en
|
||||
/// laissant la session vivante dans le registre (retry possible).
|
||||
/// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en
|
||||
/// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur.
|
||||
/// 1. Resolve the target by name and acquire its **per-agent turn lock** so two
|
||||
/// `ask`s for the same target serialise FIFO (1 agent = 1 employee).
|
||||
/// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if
|
||||
/// it is already running, otherwise launching it in the background (a normal
|
||||
/// PTH launch: a live PTY *is* the channel now, not an error as before).
|
||||
/// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot)
|
||||
/// **then write** the task into the target's terminal, prefixed with the asking
|
||||
/// agent + ticket id so the target knows to answer via `idea_reply`.
|
||||
/// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]:
|
||||
/// the target's later `idea_reply(result)` lands in [`Self::reply`] →
|
||||
/// `mailbox.resolve`, waking this await. On timeout the ticket is retired from
|
||||
/// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a
|
||||
/// typed timeout is returned (retry possible).
|
||||
/// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish
|
||||
/// [`DomainEvent::AgentReplied`].
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] si l'agent cible est inconnu ;
|
||||
/// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la
|
||||
/// cible n'est pas pilotable en mode structuré ;
|
||||
/// - [`AppError::Process`] sur échec/timeout du tour structuré.
|
||||
/// - [`AppError::NotFound`] if the target agent is unknown;
|
||||
/// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired;
|
||||
/// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout
|
||||
/// (turn timeout *or* queue-wait timeout — same typed error).
|
||||
async fn ask_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
target: String,
|
||||
task: String,
|
||||
requester: Option<AgentId>,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let structured = self.structured.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"la messagerie inter-agents (agent.message) n'est pas disponible : \
|
||||
registre des sessions structurées non câblé"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
let (input, mailbox) = match (&self.input, &self.mailbox) {
|
||||
(Some(i), Some(m)) => (i, m),
|
||||
_ => {
|
||||
return Err(AppError::Invalid(
|
||||
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let agent_id = self
|
||||
.find_agent_id_by_name(project, &target)
|
||||
let agent = self
|
||||
.find_agent_by_name(project, &target)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
|
||||
let agent_id = agent.id;
|
||||
|
||||
// Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou
|
||||
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT
|
||||
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte).
|
||||
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque
|
||||
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors.
|
||||
//
|
||||
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ;
|
||||
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock.
|
||||
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas**
|
||||
// l'attente du verrou ; cette attente a son **propre** plafond
|
||||
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition.
|
||||
// F2 — garde profil : refuser **immédiatement** une cible dont le profil ne
|
||||
// sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que
|
||||
// de laisser le round-trip échouer en timeout muet (300s).
|
||||
self.guard_mcp_bridge_supported(&agent.profile_id, &target)
|
||||
.await?;
|
||||
|
||||
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
|
||||
// cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
|
||||
// d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
|
||||
if let Some(from) = requester {
|
||||
let cycles = {
|
||||
let g = self
|
||||
.wait_for
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.would_cycle(from, agent_id)
|
||||
};
|
||||
if cycles {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"délégation ré-entrante refusée : demander à l'agent '{target}' créerait \
|
||||
un cycle d'attente inter-agents (deadlock évité)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon
|
||||
// User↔B. La session vivante est désormais keyée par conversation (lève
|
||||
// `session-registry-agent-ambiguity`).
|
||||
let conversation_id = self.resolve_conversation(requester, agent_id);
|
||||
|
||||
// Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
|
||||
// pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
|
||||
let lock = self.ask_lock_for(&agent_id);
|
||||
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(_elapsed) => {
|
||||
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) :
|
||||
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
|
||||
return Err(AppError::from(
|
||||
domain::ports::AgentSessionError::Timeout,
|
||||
));
|
||||
return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct.
|
||||
if let Some(session) = structured.session_for_agent(&agent_id) {
|
||||
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
|
||||
return Ok(self.reply_outcome(agent_id, &target, content));
|
||||
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
|
||||
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
|
||||
|
||||
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
|
||||
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
|
||||
let handle = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, &target)
|
||||
.await?;
|
||||
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
|
||||
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
|
||||
|
||||
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
|
||||
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
|
||||
// porte la source (Human/Agent) et la conversation cible.
|
||||
let requester_label = self.requester_label(project, requester).await;
|
||||
let ticket_id = TicketId::new_random();
|
||||
let ticket = match requester {
|
||||
Some(from) => {
|
||||
Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task)
|
||||
}
|
||||
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
|
||||
};
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||
|
||||
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
|
||||
// (cible laissée vivante) et renvoyer une erreur typée.
|
||||
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
|
||||
Ok(Ok(result)) => Ok(self.reply_outcome(agent_id, &target, result)),
|
||||
Ok(Err(_cancelled)) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
mailbox.cancel_head(agent_id, ticket_id);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
|
||||
///
|
||||
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
|
||||
/// thread, ensures the target is live in the PTY registry, binds its handle on the
|
||||
/// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the
|
||||
/// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a
|
||||
/// delegation serialise on the same agent («1 agent = 1 employee»).
|
||||
///
|
||||
/// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the
|
||||
/// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply
|
||||
/// slot is registered and simply left to resolve/expire on its own). The busy
|
||||
/// event is emitted at the mediator's source (Idle→Busy on the starting enqueue).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the input mediator is not wired;
|
||||
/// - [`AppError::NotFound`] if the target agent is unknown;
|
||||
/// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session.
|
||||
pub async fn submit_human_input(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
text: String,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let input = self.input.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"l'entrée médiée (submit_agent_input) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Display label for error/launch messages; the agent must exist in the
|
||||
// manifest. A human submit to an unknown id is a NotFound, never a panic.
|
||||
let target = self
|
||||
.find_name_by_agent_id(project, agent_id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?;
|
||||
let target = target.as_str();
|
||||
|
||||
// User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask.
|
||||
let conversation_id = self.resolve_conversation(None, agent_id);
|
||||
|
||||
// Ensure the target is live for this thread and bind its input handle on the
|
||||
// mediator (delivery path). Same call the ask path uses.
|
||||
let handle = self
|
||||
.ensure_live_pty(project, agent_id, conversation_id, target)
|
||||
.await?;
|
||||
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
|
||||
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
|
||||
|
||||
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
|
||||
// forget: we drop the PendingReply (the human reads the terminal). The
|
||||
// mediator emits AgentBusyChanged at the source on a starting turn.
|
||||
let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text);
|
||||
let _pending = input.enqueue(agent_id, ticket);
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("submitted human input to agent {target}"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop).
|
||||
///
|
||||
/// Resolves the target by name and calls [`InputMediator::preempt`], which signals
|
||||
/// the running turn to stop (best-effort interrupt byte to the agent's bound PTY
|
||||
/// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller
|
||||
/// is never silently answered. Idempotent: interrupting an idle agent is a no-op.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the input mediator is not wired;
|
||||
/// - [`AppError::NotFound`] if the target agent is unknown.
|
||||
pub async fn interrupt_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let input = self.input.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"l'interruption (interrupt_agent) n'est pas disponible : \
|
||||
médiateur d'entrée non câblé"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Confirm the agent exists (typed NotFound rather than a silent no-op on a
|
||||
// bogus id). The manifest lookup also keeps the contract symmetric with submit.
|
||||
if self
|
||||
.find_name_by_agent_id(project, agent_id)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::NotFound(format!("agent {agent_id}")));
|
||||
}
|
||||
|
||||
// 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal
|
||||
// brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas
|
||||
// d'ACK « launched »).
|
||||
if self.sessions.session_for_agent(&agent_id).is_some() {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"agent {target} n'est pas pilotable en mode structuré \
|
||||
(session terminal brut, pas de canal de réponse)"
|
||||
)));
|
||||
input.preempt(agent_id);
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("interrupted agent {agent_id}"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
|
||||
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
|
||||
/// stable per-agent id derived from the target (legacy routing — never panics).
|
||||
fn resolve_conversation(
|
||||
&self,
|
||||
requester: Option<AgentId>,
|
||||
target: AgentId,
|
||||
) -> domain::conversation::ConversationId {
|
||||
let left = match requester {
|
||||
Some(from) => ConversationParty::agent(from),
|
||||
None => ConversationParty::User,
|
||||
};
|
||||
let right = ConversationParty::agent(target);
|
||||
match &self.conversations {
|
||||
Some(reg) => reg.resolve(left, right).id,
|
||||
None => domain::conversation::ConversationId::from_uuid(target.as_uuid()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
|
||||
/// it is currently processing (Option 1, lot B-4).
|
||||
///
|
||||
/// Positional correlation: `from` is the **emitting** agent (its identity comes
|
||||
/// from the MCP handshake, not from a model-managed id), so the result resolves
|
||||
/// the ticket at the **head** of *that agent's* mailbox queue — the task it is
|
||||
/// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs
|
||||
/// to the asking side's `ask_agent`).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the mailbox is not wired;
|
||||
/// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no
|
||||
/// matching ask) — typed, never a panic.
|
||||
fn reply(
|
||||
&self,
|
||||
from: AgentId,
|
||||
ticket: Option<TicketId>,
|
||||
result: String,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let mailbox = self.mailbox.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(),
|
||||
)
|
||||
})?;
|
||||
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
|
||||
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
|
||||
match ticket {
|
||||
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
|
||||
None => mailbox.resolve(from, result),
|
||||
}
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
|
||||
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
|
||||
// advances to the next queued ticket. This is the deterministic OR signal that
|
||||
// pairs with prompt-ready detection; whichever fires first frees the turn. No-op
|
||||
// (and no spurious event) when the mediator is absent or `from` was already idle.
|
||||
if let Some(input) = self.input.as_ref() {
|
||||
input.mark_idle(from);
|
||||
}
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("reply from agent {from} delivered"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures the target agent has a **live PTY session**, returning its handle.
|
||||
///
|
||||
/// Reuses the running terminal when present; otherwise launches the agent in the
|
||||
/// background (a normal PTH launch — a live PTY is the delegation channel). After
|
||||
/// a launch the handle is resolved from the registry; a missing handle is a
|
||||
/// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
|
||||
/// IdeA cannot drive as a terminal).
|
||||
async fn ensure_live_pty(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
target: &str,
|
||||
) -> Result<PtyHandle, AppError> {
|
||||
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
|
||||
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
|
||||
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
|
||||
let existing = self
|
||||
.sessions
|
||||
.session_for(conversation_id)
|
||||
.or_else(|| self.sessions.session_for_agent(&agent_id));
|
||||
if let Some(session_id) = existing {
|
||||
if let Some(handle) = self.sessions.handle(&session_id) {
|
||||
// (Re)lier le fil à cette session vivante (idempotent).
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
return Ok(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cible morte : on la lance en mode structuré (background) puis on envoie.
|
||||
let launched = self
|
||||
.launch_agent
|
||||
// Dead target: launch it in the background (PTY). On injecte ici la déclaration
|
||||
// MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
|
||||
// l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc
|
||||
// à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`)
|
||||
// ⇒ déclaration minimale comme avant (dégradation gracieuse).
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: project.clone(),
|
||||
agent_id,
|
||||
@ -417,30 +912,71 @@ impl OrchestratorService {
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
// ask_agent revival launch (inside `application`): MCP runtime not
|
||||
// injected here (see the `spawn_agent` note above).
|
||||
mcp_runtime: None,
|
||||
mcp_runtime: self
|
||||
.mcp_runtime_provider
|
||||
.as_ref()
|
||||
.and_then(|p| p.runtime_for(project, agent_id)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Si le lancement n'a pas produit de session structurée, le profil de la cible
|
||||
// est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`.
|
||||
if launched.structured.is_none() {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"agent {target} n'est pas pilotable en mode structuré \
|
||||
(profil sans adaptateur structuré)"
|
||||
)));
|
||||
}
|
||||
let session_id = self
|
||||
.sessions
|
||||
.session_for_agent(&agent_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"agent {target} n'a pas de session terminal vivante après lancement"
|
||||
))
|
||||
})?;
|
||||
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
|
||||
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
|
||||
self.bind_conversation_session(conversation_id, session_id);
|
||||
self.sessions.handle(&session_id).ok_or_else(|| {
|
||||
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
|
||||
})
|
||||
}
|
||||
|
||||
// La session vient d'être enregistrée par LaunchAgent : on la récupère par
|
||||
// agent (invariant « 1 session/agent » ⇒ non ambigu).
|
||||
let session = structured.session_for_agent(&agent_id).ok_or_else(|| {
|
||||
AppError::Process(format!(
|
||||
"session structurée de l'agent {target} introuvable après lancement"
|
||||
))
|
||||
})?;
|
||||
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
|
||||
Ok(self.reply_outcome(agent_id, &target, content))
|
||||
/// Binds `conversation` to `session` in both the terminal registry (fast
|
||||
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
|
||||
/// latter is wired. Idempotent.
|
||||
fn bind_conversation_session(
|
||||
&self,
|
||||
conversation: domain::conversation::ConversationId,
|
||||
session: domain::SessionId,
|
||||
) {
|
||||
self.sessions.bind_conversation(conversation, session);
|
||||
if let Some(reg) = &self.conversations {
|
||||
reg.bind_session(conversation, SessionRef::new(session));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly label for the **requesting** agent to prefix the
|
||||
/// delegated task with. Best-effort: there is no requester id threaded into
|
||||
/// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable
|
||||
/// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can
|
||||
/// surface the real name without touching the call sites.
|
||||
async fn requester_label(&self, project: &Project, requester: Option<AgentId>) -> String {
|
||||
match requester {
|
||||
None => "un autre agent".to_owned(),
|
||||
Some(from) => self
|
||||
.find_name_by_agent_id(project, from)
|
||||
.await
|
||||
.unwrap_or_else(|| "un autre agent".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort display name for an agent id (manifest lookup). `None` when the id
|
||||
/// is unknown — the caller falls back to a generic label.
|
||||
async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option<String> {
|
||||
self.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| a.name)
|
||||
}
|
||||
|
||||
/// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
|
||||
@ -590,6 +1126,104 @@ impl OrchestratorService {
|
||||
.map(|a| a.id))
|
||||
}
|
||||
|
||||
/// Finds the full [`domain::Agent`] by display name (case-insensitive) in the
|
||||
/// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve
|
||||
/// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2.
|
||||
async fn find_agent_by_name(
|
||||
&self,
|
||||
project: &Project,
|
||||
name: &str,
|
||||
) -> Result<Option<domain::Agent>, AppError> {
|
||||
let listed = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(listed
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont
|
||||
/// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`]
|
||||
/// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip).
|
||||
///
|
||||
/// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le
|
||||
/// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json`
|
||||
/// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le
|
||||
/// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul**
|
||||
/// Claude Code lit réellement ; Codex déclare pourtant la même stratégie
|
||||
/// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont
|
||||
/// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc
|
||||
/// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui
|
||||
/// exclut Codex de fait et reste valable pour tout futur profil non-Claude.
|
||||
///
|
||||
/// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours
|
||||
/// comme avant) : la garde ne fait que transformer un échec connu en erreur typée.
|
||||
async fn guard_mcp_bridge_supported(
|
||||
&self,
|
||||
profile_id: &ProfileId,
|
||||
target: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
|
||||
let Some(profile) = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| &p.id == profile_id)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let honours_mcp_json = matches!(
|
||||
profile.mcp.as_ref().map(|c| &c.config),
|
||||
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
|
||||
);
|
||||
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
|
||||
|
||||
if honours_mcp_json && is_claude {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(AppError::Invalid(format!(
|
||||
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
|
||||
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
|
||||
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
|
||||
profil Claude.",
|
||||
profile.name, profile.structured_adapter
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
|
||||
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
|
||||
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
|
||||
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
|
||||
async fn prompt_pattern_for_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Option<String> {
|
||||
let agent = self
|
||||
.list_agents
|
||||
.execute(ListAgentsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.id == agent_id)?;
|
||||
let profiles = self.profiles.list().await.ok()?;
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.and_then(|p| p.prompt_ready_pattern)
|
||||
}
|
||||
|
||||
/// Resolves a human-friendly profile reference (slug like `claude-code`,
|
||||
/// command like `claude`, or display name like `Claude Code`) to a configured
|
||||
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
|
||||
@ -612,6 +1246,44 @@ impl OrchestratorService {
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and
|
||||
/// removes it on drop (reply, timeout, or any early return) — the same discipline as
|
||||
/// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on
|
||||
/// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the
|
||||
/// service outlives the guard.
|
||||
struct WaitEdgeGuard<'a> {
|
||||
graph: &'a StdMutex<WaitForGraph>,
|
||||
from: AgentId,
|
||||
to: AgentId,
|
||||
}
|
||||
|
||||
impl<'a> WaitEdgeGuard<'a> {
|
||||
fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self {
|
||||
{
|
||||
let mut g = service
|
||||
.wait_for
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.add_edge(from, to);
|
||||
}
|
||||
Self {
|
||||
graph: &service.wait_for,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaitEdgeGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut g = self
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g.remove_edge(self.from, self.to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalises a profile reference for tolerant matching: lowercased, with spaces,
|
||||
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
|
||||
/// → comparable forms; `claude` ⊂ ... handled by the command match above).
|
||||
|
||||
Reference in New Issue
Block a user