- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
365 lines
13 KiB
Rust
365 lines
13 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 domain::ports::ProfileStore;
|
|
use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
|
|
|
use crate::agent::{
|
|
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
|
ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
|
|
};
|
|
use crate::error::AppError;
|
|
use crate::skill::{CreateSkill, CreateSkillInput};
|
|
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
|
|
/// /cols so the spawn never fails on a zero-sized terminal.
|
|
const DEFAULT_ROWS: u16 = 24;
|
|
/// See [`DEFAULT_ROWS`].
|
|
const DEFAULT_COLS: u16 = 80;
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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::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"),
|
|
});
|
|
}
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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}")
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/// `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}"),
|
|
})
|
|
}
|
|
|
|
/// `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}"),
|
|
})
|
|
}
|
|
|
|
/// `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),
|
|
})
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|