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>
This commit is contained in:
@ -14,17 +14,18 @@
|
||||
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ports::ProfileStore;
|
||||
use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
use domain::ports::{EventBus, ProfileStore};
|
||||
use domain::{DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
|
||||
use crate::agent::{
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
|
||||
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
|
||||
@ -33,6 +34,15 @@ 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>,
|
||||
@ -43,6 +53,15 @@ 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>>,
|
||||
/// 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
|
||||
@ -51,6 +70,11 @@ pub struct OrchestratorService {
|
||||
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 {
|
||||
@ -76,9 +100,29 @@ impl OrchestratorService {
|
||||
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
|
||||
@ -100,6 +144,9 @@ impl OrchestratorService {
|
||||
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
|
||||
@ -150,6 +197,7 @@ impl OrchestratorService {
|
||||
OrchestratorVisibility::Background => {
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("agent {name} already running in background"),
|
||||
reply: None,
|
||||
});
|
||||
}
|
||||
OrchestratorVisibility::Visible { node_id } => {
|
||||
@ -163,6 +211,7 @@ impl OrchestratorService {
|
||||
})?;
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("attached agent {name} to cell {}", session.node_id),
|
||||
reply: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -193,9 +242,119 @@ impl OrchestratorService {
|
||||
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,
|
||||
@ -218,6 +377,7 @@ impl OrchestratorService {
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("stopped agent {name}"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -243,6 +403,7 @@ impl OrchestratorService {
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("updated context for agent {name}"),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -269,6 +430,7 @@ impl OrchestratorService {
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("created skill {} ({:?})", created.skill.name, scope),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user