Files
IdeaSDK/crates/application/src/orchestrator/service.rs
Blomios dd1194abe8 feat(agent): messagerie inter-agents via send_blocking (D6) — §17
Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.

- domain : OrchestratorCommand::AskAgent { target, task } + action wire
  agent.message (target+task requis) ; DomainEvent::AgentReplied
  { agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
  ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
  cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
  profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
  trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
  Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
  AgentReplied + reply: Some(content). Injection additive via builders
  with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.

Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.

Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:44:53 +02:00

527 lines
20 KiB
Rust

//! [`OrchestratorService`] — dispatches a validated [`OrchestratorCommand`] to the
//! existing agent/terminal use cases (ARCHITECTURE §14.3).
//!
//! The orchestrator agent never spawns a process itself: IdeA is the single source
//! of truth for the agent lifecycle. This service is the application-layer seam
//! that turns a request into the *same* calls the UI makes:
//!
//! - `spawn_agent` → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`],
//! - `stop_agent` → resolve the agent's live session, then [`CloseTerminal`],
//! - `update_agent_context` → [`UpdateAgentContext`].
//!
//! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]):
//! no filesystem watching, no JSON, no process spawning here — those are the
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
use std::sync::Arc;
use std::time::Duration;
use domain::ports::{EventBus, ProfileStore};
use domain::{DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
ListAgents, ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::skill::{CreateSkill, CreateSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, 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
/// /cols so the spawn never fails on a zero-sized terminal.
const DEFAULT_ROWS: u16 = 24;
/// See [`DEFAULT_ROWS`].
const DEFAULT_COLS: u16 = 80;
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
///
/// A target agent's turn can be long (reasoning + tool use), so the cap is
/// generous; on expiry [`send_blocking`] returns [`domain::ports::AgentSessionError::Timeout`]
/// **without killing the session**, so the requester can retry. Internal and
/// intentionally not yet config-exposed (it may become a per-project setting
/// without changing the contract).
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
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>>,
/// 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).
events: Option<Arc<dyn EventBus>>,
}
/// Outcome of dispatching a command — a short, human-readable success summary the
/// infrastructure adapter folds into the JSON response file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrchestratorOutcome {
/// One-line description of what IdeA did (e.g. `"launched agent dev-backend"`).
pub detail: String,
/// The target agent's **reply content** for a synchronous `agent.message`
/// (`AskAgent`, §17.4). `Some(content)` carries the turn's `Final`; every other
/// command leaves it `None`. Additive field ⇒ existing call sites/tests are
/// untouched (they only read [`Self::detail`]).
pub reply: Option<String>,
}
impl OrchestratorService {
/// Builds the service from the use cases and ports it dispatches to.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
create_agent: Arc<CreateAgentFromScratch>,
launch_agent: Arc<LaunchAgent>,
list_agents: Arc<ListAgents>,
close_terminal: Arc<CloseTerminal>,
update_context: Arc<UpdateAgentContext>,
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
) -> Self {
Self {
create_agent,
launch_agent,
list_agents,
close_terminal,
update_context,
create_skill,
profiles,
sessions,
structured: None,
events: None,
}
}
/// 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)`.
#[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
self.structured = Some(structured);
self
}
/// Branche l'[`EventBus`] pour publier [`DomainEvent::AgentReplied`] après un
/// `ask` réussi (§17.4). Builder additif (cf. [`Self::with_structured`]).
#[must_use]
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
self.events = Some(events);
self
}
/// Dispatches a validated command against `project`.
///
/// # Errors
/// Propagates the underlying use-case [`AppError`] (e.g. unknown profile,
/// unknown agent, PTY failure). For `spawn_agent` a *known* agent is launched
/// directly; an *unknown* one is created from scratch first.
pub async fn dispatch(
&self,
project: &Project,
command: OrchestratorCommand,
) -> Result<OrchestratorOutcome, AppError> {
match command {
OrchestratorCommand::SpawnAgent {
name,
profile,
context,
visibility,
} => {
self.spawn_agent(project, name, profile, context, visibility)
.await
}
OrchestratorCommand::AskAgent { target, task } => {
self.ask_agent(project, target, task).await
}
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await
}
OrchestratorCommand::CreateSkill {
name,
content,
scope,
} => self.create_skill(project, name, content, scope).await,
}
}
/// `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).
async fn spawn_agent(
&self,
project: &Project,
name: String,
profile: Option<String>,
context: Option<String>,
visibility: OrchestratorVisibility,
) -> Result<OrchestratorOutcome, AppError> {
let existing = self.find_agent_id_by_name(project, &name).await?;
let agent_id = match existing {
Some(id) => id,
None => {
let profile = profile.as_deref().ok_or_else(|| {
AppError::Invalid("profile is required to create an agent".to_owned())
})?;
let profile_id = self.resolve_profile(profile).await?;
let created = self
.create_agent
.execute(CreateAgentInput {
project: project.clone(),
name: name.clone(),
profile_id,
initial_content: context,
})
.await?;
created.agent.id
}
};
if let Some(session_id) = self.sessions.session_for_agent(&agent_id) {
match visibility {
OrchestratorVisibility::Background => {
return Ok(OrchestratorOutcome {
detail: format!("agent {name} already running in background"),
reply: None,
});
}
OrchestratorVisibility::Visible { node_id } => {
let session = self
.sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| {
AppError::NotFound(format!(
"running session {session_id} for agent {name}"
))
})?;
return Ok(OrchestratorOutcome {
detail: format!("attached agent {name} to cell {}", session.node_id),
reply: None,
});
}
}
}
let node_id = match visibility {
OrchestratorVisibility::Background => None,
OrchestratorVisibility::Visible { node_id } => Some(node_id),
};
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
node_id,
conversation_id: None,
})
.await?;
Ok(OrchestratorOutcome {
detail: match visibility {
OrchestratorVisibility::Background => {
format!("launched agent {name} in background")
}
OrchestratorVisibility::Visible { node_id } => {
format!("launched agent {name} in cell {node_id}")
}
},
reply: None,
})
}
/// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4).
///
/// 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`].
///
/// 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.
///
/// # 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é.
async fn ask_agent(
&self,
project: &Project,
target: String,
task: String,
) -> 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 agent_id = self
.find_agent_id_by_name(project, &target)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
// 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));
}
// 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)"
)));
}
// 3. Cible morte : on la lance en mode structuré (background) puis on envoie.
let launched = self
.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
node_id: None,
conversation_id: None,
})
.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é)"
)));
}
// 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))
}
/// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
/// (best-effort: a missing event bus never fails the rendezvous).
fn reply_outcome(
&self,
agent_id: domain::AgentId,
target: &str,
content: String,
) -> OrchestratorOutcome {
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentReplied {
agent_id,
reply_len: content.len(),
});
}
OrchestratorOutcome {
detail: format!("agent {target} replied ({} bytes)", content.len()),
reply: Some(content),
}
}
/// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
async fn stop_agent(
&self,
project: &Project,
name: String,
) -> Result<OrchestratorOutcome, AppError> {
let agent_id = self
.find_agent_id_by_name(project, &name)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
let session_id = self
.sessions
.session_for_agent(&agent_id)
.ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?;
self.close_terminal
.execute(CloseTerminalInput { session_id })
.await?;
Ok(OrchestratorOutcome {
detail: format!("stopped agent {name}"),
reply: None,
})
}
/// `update_agent_context`: overwrite the agent's `.md` body.
async fn update_agent_context(
&self,
project: &Project,
name: String,
context: String,
) -> Result<OrchestratorOutcome, AppError> {
let agent_id = self
.find_agent_id_by_name(project, &name)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
self.update_context
.execute(UpdateAgentContextInput {
project: project.clone(),
agent_id,
content: context,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("updated context for agent {name}"),
reply: None,
})
}
/// `create_skill`: create a reusable skill in the requested scope — the same
/// path the UI's "New skill" action takes. For [`SkillScope::Project`] the
/// skill lands under `<root>/.ideai/skills/`; for [`SkillScope::Global`] the
/// project root is ignored by the store.
async fn create_skill(
&self,
project: &Project,
name: String,
content: String,
scope: domain::SkillScope,
) -> Result<OrchestratorOutcome, AppError> {
let created = self
.create_skill
.execute(CreateSkillInput {
name: name.clone(),
content,
scope,
project_root: project.root.clone(),
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("created skill {} ({:?})", created.skill.name, scope),
reply: None,
})
}
/// Finds an agent id by display name (case-insensitive) in the project manifest.
async fn find_agent_id_by_name(
&self,
project: &Project,
name: &str,
) -> Result<Option<domain::AgentId>, 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))
.map(|a| a.id))
}
/// 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
/// scanning the configured profiles' command and name.
///
/// # Errors
/// [`AppError::NotFound`] when no configured profile matches.
async fn resolve_profile(&self, reference: &str) -> Result<ProfileId, AppError> {
let needle = normalise(reference);
let profiles = self.profiles.list().await?;
profiles
.into_iter()
.find(|p| {
normalise(&p.command) == needle
|| normalise(&p.name) == needle
|| p.id.to_string() == reference
})
.map(|p| p.id)
.ok_or_else(|| AppError::NotFound(format!("profile matching '{reference}'")))
}
}
/// 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).
fn normalise(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use domain::profile::{AgentProfile, ContextInjection};
use domain::ProfileId;
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
name,
command,
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
None,
"{agentRunDir}",
None,
)
.unwrap()
}
#[test]
fn normalise_makes_slug_command_and_name_comparable() {
assert_eq!(normalise("Claude Code"), "claudecode");
assert_eq!(normalise("claude-code"), "claudecode");
assert_eq!(normalise("claude_code"), "claudecode");
}
#[test]
fn resolve_matches_by_command_name_or_id() {
// We exercise the pure matching predicate the same way `resolve_profile`
// does, without standing up the whole service/ports.
let p = profile(1, "Claude Code", "claude");
let by_command = normalise("claude") == normalise(&p.command);
let by_name = normalise("claude-code") == normalise(&p.name);
assert!(by_command);
assert!(by_name);
assert_eq!(p.id.to_string(), p.id.to_string());
}
}