//! [`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` 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 serde_json::Value; use domain::ports::{ AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan, StructuredProviderLaunchPolicy, ToolInvocationError, ToolInvoker, ToolSpec, }; 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; use super::openai_compat::OpenAiCompatibleSession; use super::opencode::OpenCodeSession; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const REQUESTER_ARG: &str = "__ideaRequester"; struct ProjectScopedToolInvoker { inner: Arc, project_root: String, requester: String, } #[async_trait] impl ToolInvoker for ProjectScopedToolInvoker { fn tools(&self) -> Vec { self.inner.tools() } async fn tools_for_bound_context(&self) -> Result, ToolInvocationError> { self.inner .tools_for_context(&self.project_root, &self.requester) .await } async fn call(&self, name: &str, args_json: &str) -> Result { let mut value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; match &mut value { Value::Object(map) => { map.insert( PROJECT_ROOT_ARG.to_owned(), Value::String(self.project_root.clone()), ); map.insert( REQUESTER_ARG.to_owned(), Value::String(self.requester.clone()), ); } _ => { return Err(ToolInvocationError::InvalidArguments( "les arguments d'outil doivent être un objet JSON".to_owned(), )); } } self.inner.call(name, &value.to_string()).await } } /// 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>, /// Invoker outil optionnel pour les adapters sans client MCP natif. tool_invoker: Option>, } impl StructuredSessionFactory { /// Construit la fabrique (sans enforcer : chemin natif). #[must_use] pub fn new() -> Self { Self { sandbox_enforcer: None, tool_invoker: 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) -> Self { self.sandbox_enforcer = Some(enforcer); self } /// Builder additif : câble l'invocation d'outils `idea_*` pour les moteurs HTTP /// sans client MCP natif. `None` (défaut) ⇒ chat nu. #[must_use] pub fn with_tool_invoker(mut self, invoker: Arc) -> Self { self.tool_invoker = Some(invoker); 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 { 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, requester: Option<&str>, env: &[(String, String)], sandbox: Option<&SandboxPlan>, structured_policy: Option<&StructuredProviderLaunchPolicy>, ) -> Result, 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); let requester = requester .map(str::to_owned) .unwrap_or_else(|| fallback_requester_from_cwd(&cwd)); // 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 = match adapter { StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new( id, command, cwd, seed, plan, enforcer, )), StructuredAdapter::Codex => { let policy = match structured_policy { Some(StructuredProviderLaunchPolicy::Codex { sandbox_mode, writable_roots, network_access, }) => ( sandbox_mode.clone(), writable_roots.clone(), Some(*network_access), ), None => ( "workspace-write".to_owned(), vec![ctx.project_root.clone()], None, ), }; Arc::new(CodexExecSession::new_with_policy( id, command, cwd, seed, policy.0, policy.1, policy.2, env.to_vec(), plan, enforcer, )) } StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new( id, profile.command.clone(), profile.args.clone(), cwd, seed, env.to_vec(), plan, enforcer, )?), StructuredAdapter::OpenAiCompatible => { let config = profile.chat_http.clone().ok_or_else(|| { AgentSessionError::Start(format!( "le profil « {} » n'a pas de configuration chatHttp", profile.name )) })?; Arc::new(OpenAiCompatibleSession::new( id, config, &cwd, ctx.content.as_str(), self.tool_invoker.clone().map(|inner| { Arc::new(ProjectScopedToolInvoker { inner, project_root: ctx.project_root.clone(), requester: requester.clone(), }) as Arc }), )?) } }; Ok(session) } } fn fallback_requester_from_cwd(cwd: &str) -> String { std::path::Path::new(cwd) .file_name() .and_then(|name| name.to_str()) .unwrap_or_default() .to_owned() } #[cfg(test)] mod tests { use std::sync::Mutex; use serde_json::json; use super::*; #[derive(Default)] struct RecordingToolInvoker { call: Mutex>, tools_context: Mutex>, } #[async_trait] impl ToolInvoker for RecordingToolInvoker { fn tools(&self) -> Vec { Vec::new() } async fn tools_for_context( &self, project_root: &str, requester: &str, ) -> Result, ToolInvocationError> { *self.tools_context.lock().unwrap() = Some((project_root.to_owned(), requester.to_owned())); Ok(vec![ToolSpec { name: "idea_memory_read".to_owned(), description: "Read memory".to_owned(), input_schema: json!({"type":"object"}), }]) } async fn call(&self, name: &str, args_json: &str) -> Result { *self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned())); Ok("ok".to_owned()) } } #[tokio::test] async fn project_scoped_tool_invoker_injects_explicit_requester_identity() { let recorder = Arc::new(RecordingToolInvoker::default()); let invoker = ProjectScopedToolInvoker { inner: recorder.clone(), project_root: "/project/root".to_owned(), requester: "ticket-assistant:project:7".to_owned(), }; let out = invoker .call("idea_ticket_update", r##"{"ref":"#7"}"##) .await .expect("tool call ok"); assert_eq!(out, "ok"); let (name, args_json) = recorder.call.lock().unwrap().clone().expect("recorded"); assert_eq!(name, "idea_ticket_update"); let args: Value = serde_json::from_str(&args_json).unwrap(); assert_eq!( args, json!({ "ref": "#7", PROJECT_ROOT_ARG: "/project/root", REQUESTER_ARG: "ticket-assistant:project:7", }) ); } #[tokio::test] async fn project_scoped_tool_invoker_filters_tools_with_bound_context() { let recorder = Arc::new(RecordingToolInvoker::default()); let invoker = ProjectScopedToolInvoker { inner: recorder.clone(), project_root: "/project/root".to_owned(), requester: "agent-1".to_owned(), }; let tools = invoker .tools_for_bound_context() .await .expect("tools resolve"); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "idea_memory_read"); assert_eq!( recorder.tools_context.lock().unwrap().clone(), Some(("/project/root".to_owned(), "agent-1".to_owned())) ); } #[test] fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() { assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7"); } }