Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).
Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
5.3 KiB
Rust
128 lines
5.3 KiB
Rust
//! [`StructuredSessionFactory`] — la fabrique [`AgentSessionFactory`] qui **route un
|
|
//! profil vers le bon adapter** structuré (ARCHITECTURE §17.2) selon
|
|
//! `profile.structured_adapter` (§17.3). Agrège Claude + Codex derrière une seule
|
|
//! surface ; aucun type concret ne franchit la frontière domaine (seuls
|
|
//! `Arc<dyn AgentSession>` sortent).
|
|
//!
|
|
//! Open/Closed : ajouter un moteur structuré = ajouter un adapter + une variante
|
|
//! [`StructuredAdapter`] + un bras de `match` ici. Le cœur ne bouge pas.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::ports::{
|
|
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
|
|
};
|
|
use domain::profile::{AgentProfile, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
|
use domain::SessionId;
|
|
|
|
use super::claude::ClaudeSdkSession;
|
|
use super::codex::CodexExecSession;
|
|
|
|
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
|
|
///
|
|
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
|
|
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
|
|
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
|
|
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
|
|
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
|
|
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
|
|
#[derive(Clone, Default)]
|
|
pub struct StructuredSessionFactory {
|
|
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
|
|
/// sandboxing (chemin natif inchangé, zéro régression).
|
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
|
}
|
|
|
|
impl StructuredSessionFactory {
|
|
/// Construit la fabrique (sans enforcer : chemin natif).
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
sandbox_enforcer: None,
|
|
}
|
|
}
|
|
|
|
/// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
|
|
/// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
|
|
/// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
|
|
/// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
|
|
#[must_use]
|
|
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
|
self.sandbox_enforcer = Some(enforcer);
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
|
|
/// amorce l'adapter avec un id de conversation existant ; `Assign`/`None` partent
|
|
/// d'une conversation neuve (l'id sera capté au premier tour).
|
|
fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
|
|
match session {
|
|
SessionPlan::Resume { conversation_id } => Some(conversation_id.clone()),
|
|
SessionPlan::None | SessionPlan::Assign { .. } => None,
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSessionFactory for StructuredSessionFactory {
|
|
fn supports(&self, profile: &AgentProfile) -> bool {
|
|
// Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en
|
|
// structuré. `is_selectable` et `supports` partagent ainsi le **même**
|
|
// prédicat de domaine, ils ne peuvent pas diverger.
|
|
profile.is_selectable()
|
|
}
|
|
|
|
async fn start(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
session: &SessionPlan,
|
|
sandbox: Option<&SandboxPlan>,
|
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
|
let adapter = profile.structured_adapter.ok_or_else(|| {
|
|
AgentSessionError::Start(format!(
|
|
"le profil « {} » n'a pas d'adapter structuré",
|
|
profile.name
|
|
))
|
|
})?;
|
|
|
|
let id = SessionId::new_random();
|
|
let command = profile.command.clone();
|
|
let cwd = cwd.as_str().to_owned();
|
|
let seed = seed_conversation_id(session);
|
|
|
|
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
|
|
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
|
|
// `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
|
|
// l'adapter reste sur le drain async natif.
|
|
let plan = sandbox.cloned();
|
|
let enforcer = self.sandbox_enforcer.clone();
|
|
|
|
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
|
|
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
|
|
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
|
|
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
|
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
|
let session: Arc<dyn AgentSession> = match adapter {
|
|
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
|
id, command, cwd, seed, plan, enforcer,
|
|
)),
|
|
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
|
|
id,
|
|
command,
|
|
cwd,
|
|
seed,
|
|
vec![ctx.project_root.clone()],
|
|
plan,
|
|
enforcer,
|
|
)),
|
|
};
|
|
Ok(session)
|
|
}
|
|
}
|