//! ToolInvoker app-tauri pour l'adapter OpenAI-compatible. //! //! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que //! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même //! `OrchestratorService::dispatch`. use std::str::FromStr; use std::sync::{Arc, Mutex}; use application::OrchestratorService; use async_trait::async_trait; use domain::ports::{ AgentToolPolicyStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, }; use domain::{AgentToolPolicy, IssueRef}; use infrastructure::TicketToolProvider; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const REQUESTER_ARG: &str = "__ideaRequester"; /// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif. pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, ticket_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la /// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime. #[derive(Default)] pub struct LateBoundOpenAiToolInvoker { inner: Mutex>>, } impl LateBoundOpenAiToolInvoker { /// Construit un proxy vide. #[must_use] pub fn new() -> Self { Self { inner: Mutex::new(None), } } /// Lie l'implémentation réelle. Appelé une fois par la composition root. pub fn bind(&self, inner: Arc) { *self.inner.lock().expect("mutex sain") = Some(inner); } } #[async_trait] impl ToolInvoker for LateBoundOpenAiToolInvoker { fn tools(&self) -> Vec { self.inner .lock() .expect("mutex sain") .as_ref() .map_or_else(Vec::new, |inner| inner.tools()) } async fn call(&self, name: &str, args_json: &str) -> Result { let inner = self .inner .lock() .expect("mutex sain") .clone() .ok_or_else(|| { ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) })?; inner.call(name, args_json).await } } impl AppOpenAiToolInvoker { /// Construit l'invoker depuis le service orchestrateur et le store projet. #[must_use] pub fn new( orchestrator: Arc, projects: Arc, policies: Arc, ticket_tools: Arc, ) -> Self { Self { orchestrator, projects, policies, ticket_tools, } } } #[async_trait] impl ToolInvoker for AppOpenAiToolInvoker { fn tools(&self) -> Vec { infrastructure::orchestrator::mcp::catalogue() .into_iter() .map(|tool| ToolSpec { name: tool.name.to_owned(), description: tool.description.to_owned(), input_schema: tool.input_schema, }) .collect() } async fn call(&self, name: &str, args_json: &str) -> Result { let value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; let args = value.as_object().ok_or_else(|| { ToolInvocationError::InvalidArguments( "les arguments d'outil doivent être un objet JSON".to_owned(), ) })?; let project_root = args .get(PROJECT_ROOT_ARG) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| { ToolInvocationError::InvalidArguments( "contexte projet interne absent pour l'outil".to_owned(), ) })?; let requester = args .get(REQUESTER_ARG) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| { ToolInvocationError::InvalidArguments( "identité requester interne absente pour l'outil".to_owned(), ) })?; enforce_tool_policy(self.policies.as_ref(), &requester, name, &value)?; let project = self .projects .list_projects() .await .map_err(|e| ToolInvocationError::Execution(e.to_string()))? .into_iter() .find(|project| project.root.as_str() == project_root) .ok_or_else(|| { ToolInvocationError::Execution(format!( "projet introuvable pour root `{project_root}`" )) })?; if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) { let value = self .ticket_tools .handle_ticket_tool(&project, &requester, name, value) .await .map_err(|e| { let detail = serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); ToolInvocationError::Execution(detail) })?; return serde_json::to_string(&value) .map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { ToolInvocationError::NotFound(tool) } infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => { ToolInvocationError::InvalidArguments(format!( "arguments invalides pour `{tool}`" )) } infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => { ToolInvocationError::InvalidArguments(err.to_string()) } })?; let outcome = self .orchestrator .dispatch(&project, command) .await .map_err(|e| ToolInvocationError::Execution(e.to_string()))?; Ok(outcome.reply.unwrap_or(outcome.detail)) } } fn enforce_tool_policy( policies: &dyn AgentToolPolicyStore, requester: &str, name: &str, arguments: &Value, ) -> Result<(), ToolInvocationError> { let Some(policy) = policies.get_policy(requester) else { return Ok(()); }; enforce_policy(&policy, requester, name, arguments) } fn enforce_policy( policy: &AgentToolPolicy, requester: &str, name: &str, arguments: &Value, ) -> Result<(), ToolInvocationError> { if !policy.permits(name) { return Err(ToolInvocationError::Rejected(format!( "tool `{name}` is not permitted for requester {requester}" ))); } if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) .ok_or_else(|| { ToolInvocationError::InvalidArguments(format!( "tool `{name}` requires a ticket ref under the active policy" )) })?; let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { ToolInvocationError::InvalidArguments(format!("invalid ticket ref: {e}")) })?; if !policy.permits_ticket_mutation(name, issue_ref) { return Err(ToolInvocationError::Rejected(format!( "tool `{name}` is not permitted for ticket {issue_ref}" ))); } } Ok(()) } fn is_ticket_policy_mutation_tool(name: &str) -> bool { matches!( name, "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" | "idea_ticket_link" | "idea_ticket_unlink" ) }