diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 486dfd52..f805fb0c 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -540,6 +540,7 @@ mod open_project_template_tests { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: false, }) .await .expect("template is created") diff --git a/crates/app-tauri/src/templates.rs b/crates/app-tauri/src/templates.rs index fbd18023..60dba708 100644 --- a/crates/app-tauri/src/templates.rs +++ b/crates/app-tauri/src/templates.rs @@ -78,6 +78,8 @@ impl TemplateToolProvider for AppTemplateToolProvider { "defaultMcpToolPermissions", "defaultMcpToolPolicy", )?, + default_contactable: optional_bool(&arguments, "defaultContactable")? + .unwrap_or(false), }) .await .map_err(template_error)?; @@ -160,6 +162,17 @@ fn optional_string<'a>( } } +fn optional_bool(value: &Value, key: &str) -> Result, TemplateToolError> { + match value.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(TemplateToolError::new( + "invalid", + format!("field `{key}` must be a boolean"), + )), + } +} + fn optional_json_value(arguments: &Value, key: &str) -> Result, TemplateToolError> where T: serde::de::DeserializeOwned, @@ -317,6 +330,7 @@ mod tests { "defaultAgentName": "Base Agent", "defaultSystemPermissions": { "network": "allow" }, "defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] }, + "defaultContactable": true, }), ) .await @@ -332,6 +346,7 @@ mod tests { created["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_read"] }) ); + assert_eq!(created["defaultContactable"], true); let read = provider .handle_template_tool( @@ -353,6 +368,7 @@ mod tests { read["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_read"] }) ); + assert_eq!(read["defaultContactable"], true); let listed = provider .handle_template_tool(&project(), "agent", "idea_template_list", json!({})) @@ -370,6 +386,7 @@ mod tests { "content": "v2", "defaultSystemPermissions": { "network": "deny" }, "defaultMcpToolPermissions": { "allowedTools": ["idea_template_update"] }, + "defaultContactable": false, }), ) .await @@ -385,6 +402,7 @@ mod tests { updated["defaultMcpToolPermissions"], json!({ "allowedTools": ["idea_template_update"] }) ); + assert_eq!(updated["defaultContactable"], false); assert_eq!( store .get(TemplateId::from_uuid( diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 03213f35..099c0ae2 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -790,6 +790,7 @@ mod tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }], }, contexts: Mutex::new(contexts), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 68132af6..218e4971 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -25,7 +25,8 @@ use domain::conversation_log::{ConversationTurn, TurnId, TurnRole}; use domain::input::{InputMediator, InputSource, SubmitConfig}; use domain::mailbox::{Ticket, TicketId}; use domain::ports::{ - BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle, + BackgroundTaskRunner, BackgroundTaskStore, Clock, EventBus, ProfileStore, ProjectStore, + PtyHandle, }; use domain::project::ProjectPath; use domain::{ @@ -407,6 +408,7 @@ pub struct OrchestratorService { update_context: Arc, create_skill: Arc, profiles: Arc, + projects: Option>, sessions: Arc, /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket @@ -460,7 +462,7 @@ pub struct OrchestratorService { /// /// Croissance bornée en pratique au nombre d'agents du projet ; une entrée /// morte ne coûte qu'un `Arc>` vide (pas de session, pas de process). - ask_locks: StdMutex>>>, + ask_locks: StdMutex>>>, /// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration /// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask` /// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis @@ -600,6 +602,7 @@ impl OrchestratorService { update_context, create_skill, profiles, + projects: None, sessions, input: None, mailbox: None, @@ -634,6 +637,13 @@ impl OrchestratorService { self } + /// Branche le registre des projets connus pour les outils MCP cross-project. + #[must_use] + pub fn with_project_store(mut self, projects: Arc) -> Self { + self.projects = Some(projects); + self + } + /// Branche le producteur B8 command-backed pour `idea_run_in_background`. #[must_use] pub fn with_spawn_background_command(mut self, spawn: Arc) -> Self { @@ -719,12 +729,12 @@ impl OrchestratorService { /// never across an `.await`); the returned `Arc>` is the lock the /// caller acquires (and holds across `send_blocking`) to serialise turns for /// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention. - fn ask_lock_for(&self, agent_id: &AgentId) -> Arc> { + fn ask_lock_for(&self, project: &Project, agent_id: AgentId) -> Arc> { let mut locks = self .ask_locks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - Arc::clone(locks.entry(*agent_id).or_default()) + Arc::clone(locks.entry(runtime_key(project, agent_id)).or_default()) } fn active_rendezvous_link( @@ -1301,6 +1311,125 @@ impl OrchestratorService { } } + fn require_project_store(&self) -> Result<&Arc, AppError> { + self.projects.as_ref().ok_or_else(|| { + AppError::Invalid("cross-project MCP tools are not configured".to_owned()) + }) + } + + async fn load_project_for_cross_tool( + &self, + project_id: domain::ProjectId, + ) -> Result { + self.require_project_store()? + .load_project(project_id) + .await + .map_err(AppError::from) + } + + /// Lists every project known to this IdeA instance for cross-project MCP discovery. + pub async fn list_projects_for_mcp(&self) -> Result { + let projects = self + .require_project_store()? + .list_projects() + .await + .map_err(AppError::from)?; + let reply = serde_json::to_string(&projects) + .map_err(|e| AppError::Invalid(format!("failed to serialise project list: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("listed {} project(s)", projects.len()), + reply: Some(reply), + }) + } + + /// Reads the target project's global context, even when it is not currently open. + pub async fn read_project_context_for_mcp( + &self, + project_id: domain::ProjectId, + requester: ConversationParty, + ) -> Result { + let project = self.load_project_for_cross_tool(project_id).await?; + self.read_context(&project, None, requester).await + } + + /// Lists contactable agents in the target project, even when it is not currently open. + pub async fn list_project_agents_for_mcp( + &self, + project_id: domain::ProjectId, + ) -> Result { + let project = self.load_project_for_cross_tool(project_id).await?; + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + let entries: Vec<_> = listed + .discovery_entries() + .into_iter() + .filter(|entry| entry.agent.contactable) + .collect(); + let reply = serde_json::to_string(&entries) + .map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("listed {} contactable agent(s)", entries.len()), + reply: Some(reply), + }) + } + + /// Asks one contactable agent in another project by stable ids. + pub async fn ask_project_agent_for_mcp( + &self, + target_project_id: domain::ProjectId, + target_agent_id: AgentId, + task: String, + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, + ) -> Result { + let project = self.load_project_for_cross_tool(target_project_id).await?; + let target = self + .find_agent_by_id(&project, target_agent_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?; + if !target.contactable { + return Err(AppError::Invalid(format!( + "agent {target_agent_id} is not contactable cross-project" + ))); + } + crate::diag!( + "[cross-project] ask correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" + ); + self.ask_agent(&project, target.name, task, Some(requester_agent_id)) + .await + } + + /// Cancels/stops one contactable agent in another project by stable ids. + pub async fn cancel_project_agent_for_mcp( + &self, + target_project_id: domain::ProjectId, + target_agent_id: AgentId, + requester_project_id: domain::ProjectId, + requester_agent_id: AgentId, + correlation_id: String, + ) -> Result { + let project = self.load_project_for_cross_tool(target_project_id).await?; + let target = self + .find_agent_by_id(&project, target_agent_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("agent {target_agent_id}")))?; + if !target.contactable { + return Err(AppError::Invalid(format!( + "agent {target_agent_id} is not contactable cross-project" + ))); + } + crate::diag!( + "[cross-project] cancel correlation={correlation_id} requester={requester_project_id}/{requester_agent_id} target={target_project_id}/{target_agent_id}" + ); + let _ = requester_agent_id; + self.stop_agent(&project, target.name, None).await + } + async fn run_in_background( &self, project: &Project, @@ -1818,7 +1947,7 @@ impl OrchestratorService { // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return. - let lock = self.ask_lock_for(&agent_id); + let lock = self.ask_lock_for(project, agent_id); // Diagnostics : tracer l'attente du verrou de tour. Un « turn-lock waiting » sans // « turn-lock acquired » qui suit signe un tour précédent jamais relâché (la cible // reste Busy) — le verrou n'est libéré qu'à la fin du tour courant. @@ -2886,6 +3015,21 @@ impl OrchestratorService { .find(|a| a.name.eq_ignore_ascii_case(name))) } + /// Finds the full [`domain::Agent`] by stable id in the project manifest. + async fn find_agent_by_id( + &self, + project: &Project, + agent_id: AgentId, + ) -> Result, AppError> { + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + Ok(listed.agents.into_iter().find(|a| a.id == agent_id)) + } + /// Resolves the target agent profile's **submit config** /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it /// declares an **MCP bridge**, in a single profile lookup. The submit config is diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index 62e2b6ea..d96478ba 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -45,6 +45,8 @@ pub struct CreateTemplateInput { pub default_system_permissions: Option, /// Optional agent-level MCP tool permission override applied at creation time. pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + pub default_contactable: bool, } /// Output of [`CreateTemplate::execute`]. @@ -90,6 +92,7 @@ impl CreateTemplate { input.default_agent_name, input.default_system_permissions, input.default_mcp_tool_policy, + input.default_contactable, ) }) .map_err(|e| AppError::Invalid(e.to_string()))?; @@ -122,6 +125,8 @@ pub struct UpdateTemplateInput { /// New default MCP tool policy. Outer `None` leaves the current value; inner /// `None` preserves project inheritance for future agents. pub default_mcp_tool_policy: Option>, + /// New default contactable flag. `None` leaves the current value. + pub default_contactable: Option, } /// Output of [`UpdateTemplate::execute`]. @@ -175,6 +180,9 @@ impl UpdateTemplate { input .default_mcp_tool_policy .unwrap_or_else(|| updated.default_mcp_tool_policy.clone()), + input + .default_contactable + .unwrap_or(updated.default_contactable), ) .map_err(|e| AppError::Invalid(e.to_string()))?; self.templates.save(&updated).await?; @@ -397,7 +405,8 @@ impl CreateAgentFromTemplate { origin, input.synchronized, ) - .map_err(|e| AppError::Invalid(e.to_string()))?; + .map_err(|e| AppError::Invalid(e.to_string()))? + .with_contactable(template.default_contactable); let mut entries = manifest.entries; entries.push(ManifestEntry::from_agent(&agent)); diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index f887c30c..8826a7f2 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -24,8 +24,9 @@ use domain::ports::{ AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, - IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError, - PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, + ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, + SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, @@ -172,6 +173,45 @@ impl ProfileStore for FakeProfiles { } } +#[derive(Clone)] +struct FakeProjects(Arc>); + +impl FakeProjects { + fn new(projects: Vec) -> Self { + Self(Arc::new(projects)) + } +} + +#[async_trait] +impl ProjectStore for FakeProjects { + async fn list_projects(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.0 + .iter() + .find(|project| project.id == id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { + Ok(()) + } + + async fn save_workspace( + &self, + _workspace: &domain::layout::Workspace, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(domain::layout::Workspace::default()) + } +} + // Empty skill store: the orchestrator tests spawn agents with no assigned skills. #[derive(Default)] struct FakeSkills; @@ -795,10 +835,103 @@ fn cmd(json: &str) -> OrchestratorCommand { .unwrap() } +fn other_project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(2000)), + "other", + ProjectPath::new("/home/me/other").unwrap(), + RemoteRef::local(), + 1_700_000_000_001, + ) + .unwrap() +} + // --------------------------------------------------------------------------- // Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands. // --------------------------------------------------------------------------- +#[tokio::test] +async fn cross_project_list_projects_reads_known_project_registry() { + let fx = fixture(FakeContexts::new()); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![ + project(), + other_project(), + ]))); + + let out = svc + .list_projects_for_mcp() + .await + .expect("project registry is readable"); + let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap(); + + assert_eq!(rows.as_array().unwrap().len(), 2); + assert_eq!(rows[0]["id"], project_id().to_string()); + assert_eq!(rows[1]["id"], other_project().id.to_string()); +} + +#[tokio::test] +async fn cross_project_list_agents_filters_to_contactable_agents() { + let visible = scratch_agent(aid(1), "Visible", "agents/visible.md").with_contactable(true); + let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md"); + let contexts = { + let me = FakeContexts::with_agent(&visible, "# visible"); + let mut inner = me.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(&hidden)); + inner + .contents + .insert(hidden.context_path.clone(), "# hidden".to_owned()); + drop(inner); + me + }; + let fx = fixture(contexts); + let target_project = other_project(); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + + let out = svc + .list_project_agents_for_mcp(target_project.id) + .await + .expect("agent manifest is readable"); + let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap(); + + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["id"], visible.id.to_string()); + assert_eq!(rows[0]["name"], "Visible"); + assert_eq!(rows[0]["contactable"], true); +} + +#[tokio::test] +async fn cross_project_ask_rejects_non_contactable_agent_before_launch() { + let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md"); + let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden")); + let target_project = other_project(); + let svc = fx + .service + .with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()]))); + + let err = svc + .ask_project_agent_for_mcp( + target_project.id, + hidden.id, + "work".to_owned(), + project().id, + aid(99), + "corr-1".to_owned(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("not contactable")); + assert!(fx.pty.spawns().is_empty(), "agent must not be launched"); +} + #[tokio::test] async fn spawn_unknown_agent_creates_then_launches() { let fx = fixture(FakeContexts::new()); diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index 78ecf5f4..978ac828 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -282,6 +282,7 @@ async fn create_template_starts_at_initial_version() { default_mcp_tool_policy: Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + default_contactable: true, }) .await .unwrap(); @@ -341,6 +342,7 @@ async fn update_template_bumps_version_and_publishes_event() { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: None, }) .await .unwrap(); @@ -370,6 +372,7 @@ async fn update_unknown_template_is_not_found() { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: None, }) .await .unwrap_err(); @@ -393,6 +396,7 @@ async fn update_template_updates_default_profile_for_future_agents() { default_mcp_tool_policy: Some(Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], })), + default_contactable: Some(true), }) .await .unwrap() @@ -411,6 +415,7 @@ async fn update_template_updates_default_profile_for_future_agents() { allowed_tools: vec!["idea_template_read".to_owned()] }) ); + assert!(updated.default_contactable); let contexts = FakeContexts::new(vec![]); let out = CreateAgentFromTemplate::new( @@ -427,6 +432,7 @@ async fn update_template_updates_default_profile_for_future_agents() { }) .await .unwrap(); + assert!(out.agent.contactable); assert_eq!(out.agent.name, "Agent Name"); assert_eq!(out.agent.profile_id, pid(42)); @@ -486,6 +492,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); let store = FakeTemplates::with(vec![template]); @@ -512,6 +519,7 @@ async fn create_agent_from_template_projects_template_permission_overrides() { assert_eq!(out.agent.name, "Backend"); assert_eq!(out.agent.profile_id, pid(7)); + assert!(out.agent.contactable); assert_eq!( system_permissions.0.lock().unwrap().agents, vec![AgentSystemPermissionOverride::new( diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index f0f49250..e31fdb9a 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -4295,6 +4295,8 @@ pub struct CreateTemplateRequestDto { pub default_system_permissions: Option, /// Optional agent-level MCP tool policy override for created agents. pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + pub default_contactable: bool, } impl<'de> Deserialize<'de> for CreateTemplateRequestDto { @@ -4327,6 +4329,10 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto { ) .map_err(serde::de::Error::custom)? .flatten(); + let default_contactable = take_optional_value::(object, "defaultContactable") + .map_err(serde::de::Error::custom)? + .flatten() + .unwrap_or(false); Ok(Self { name, content, @@ -4334,6 +4340,7 @@ impl<'de> Deserialize<'de> for CreateTemplateRequestDto { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, }) } } @@ -4351,6 +4358,7 @@ impl CreateTemplateRequestDto { default_agent_name: self.default_agent_name, default_system_permissions: self.default_system_permissions, default_mcp_tool_policy: self.default_mcp_tool_policy, + default_contactable: self.default_contactable, }) } } @@ -4372,6 +4380,8 @@ pub struct UpdateTemplateRequestDto { pub default_system_permissions: Option>, /// New default MCP tool policy. Missing leaves it unchanged; null clears it. pub default_mcp_tool_policy: Option>, + /// New default contactable flag. Missing leaves it unchanged. + pub default_contactable: Option, } impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { @@ -4408,6 +4418,9 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { ], ) .map_err(serde::de::Error::custom)?; + let default_contactable = take_optional_value::(object, "defaultContactable") + .map_err(serde::de::Error::custom)? + .flatten(); Ok(Self { template_id, content, @@ -4416,6 +4429,7 @@ impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, }) } } @@ -4438,6 +4452,7 @@ impl UpdateTemplateRequestDto { default_agent_name: self.default_agent_name, default_system_permissions: self.default_system_permissions, default_mcp_tool_policy: self.default_mcp_tool_policy, + default_contactable: self.default_contactable, }) } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index a472e8d0..6b0bdae5 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -3033,6 +3033,7 @@ impl BackendCore { // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison // PTV sérialisée) + registre de conversations + bus pour AgentReplied. .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox)) + .with_project_store(Arc::clone(&store_port)) .with_conversations(Arc::clone(&conversation_registry)) .with_events(Arc::clone(&events_port)) // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour @@ -4973,6 +4974,7 @@ mod mcp_serve_peer_tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } @@ -6310,6 +6312,7 @@ mod mcp_e2e_loopback_tests { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index 278551d9..f50099b1 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -75,6 +75,9 @@ pub struct Agent { /// `model_reasoning_effort` at launch. #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, + /// Whether this agent may be discovered/contacted from another project. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contactable: bool, } impl Agent { @@ -110,6 +113,7 @@ impl Agent { synchronized, skills: Vec::new(), effort: None, + contactable: false, }) } @@ -141,6 +145,13 @@ impl Agent { self } + /// Returns a copy with the cross-project contact permission changed. + #[must_use] + pub const fn with_contactable(mut self, contactable: bool) -> Self { + self.contactable = contactable; + self + } + /// Assigns a skill to this agent. Idempotent: re-assigning the same /// `skill_id` is a no-op (returns `false`); a new assignment returns `true`. pub fn assign_skill(&mut self, skill: SkillRef) -> bool { @@ -199,6 +210,9 @@ pub struct ManifestEntry { /// Per-agent effort selection. Missing in older manifests means no override. #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, + /// Whether this agent may be discovered/contacted from another project. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub contactable: bool, } impl ManifestEntry { @@ -238,6 +252,7 @@ impl ManifestEntry { synced_template_version, skills: Vec::new(), effort: None, + contactable: false, }) } @@ -264,6 +279,7 @@ impl ManifestEntry { synced_template_version, skills: agent.skills.clone(), effort: agent.effort.clone(), + contactable: agent.contactable, } } @@ -290,7 +306,8 @@ impl ManifestEntry { self.synchronized, )? .with_skills(self.skills.clone()) - .with_effort(self.effort.clone())) + .with_effort(self.effort.clone()) + .with_contactable(self.contactable)) } } diff --git a/crates/domain/src/template.rs b/crates/domain/src/template.rs index 0b9ea5c9..48259e33 100644 --- a/crates/domain/src/template.rs +++ b/crates/domain/src/template.rs @@ -74,6 +74,9 @@ pub struct AgentTemplate { skip_serializing_if = "Option::is_none" )] pub default_mcp_tool_policy: Option, + /// Whether agents created from this template are cross-project contactable. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub default_contactable: bool, } impl AgentTemplate { @@ -98,6 +101,7 @@ impl AgentTemplate { default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, + default_contactable: false, }) } @@ -125,6 +129,7 @@ impl AgentTemplate { default_agent_name: Option, default_system_permissions: Option, default_mcp_tool_policy: Option, + default_contactable: bool, ) -> Result { crate::validation::non_empty(&name, "template.name")?; if let Some(default_agent_name) = &default_agent_name { @@ -136,6 +141,7 @@ impl AgentTemplate { default_agent_name, default_system_permissions, default_mcp_tool_policy, + default_contactable, ..self.clone() }) } diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 368da9f9..8ba9424e 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -24,7 +24,7 @@ use application::OrchestratorService; use domain::ports::McpToolPermissionStore; use domain::{ AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, - OrchestratorCommand, Project, + OrchestratorCommand, Project, ProjectId, }; use futures_util::future::join_all; use serde_json::{json, Value}; @@ -483,6 +483,33 @@ impl McpServer { .await; } + if is_project_tool(&name) { + let result = self.tools_call_project(&name, arguments).await; + self.publish_processed(&name, result.is_ok()); + return match result { + Ok(outcome) => { + let text = outcome.reply.unwrap_or(outcome.detail); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}", + text.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&text, false)) + } + Err(err) => { + let detail = err.to_string(); + application::diag!( + "[mcp] tools_call end tool={name} requester={requester_label} \ + target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}", + detail.len(), + started.elapsed().as_millis(), + ); + Ok(tool_result_text(&detail, true)) + } + }; + } + if tools::is_ticket_tool(&name) { let result = match &self.ticket_tools { Some(provider) => { @@ -709,6 +736,78 @@ impl McpServer { Ok(tool_result_text(&text, false)) } + async fn tools_call_project( + &self, + name: &str, + arguments: Value, + ) -> Result { + let args = arguments.as_object().ok_or_else(|| { + application::AppError::Invalid(format!("tool `{name}` arguments must be a JSON object")) + })?; + let project_id = || parse_project_id(required_str_arg(args, "projectId")?); + let agent_id = || parse_agent_id(required_str_arg(args, "agentId")?); + match name { + "idea_project_list" => { + reject_extra_project_args(args, &[])?; + self.service.list_projects_for_mcp().await + } + "idea_project_read_context" => { + reject_extra_project_args(args, &["projectId"])?; + self.service + .read_project_context_for_mcp(project_id()?, requester_party(&self.requester)) + .await + } + "idea_project_list_agents" => { + reject_extra_project_args(args, &["projectId"])?; + self.service + .list_project_agents_for_mcp(project_id()?) + .await + } + "idea_project_ask_agent" => { + reject_extra_project_args( + args, + &["projectId", "agentId", "correlationId", "task"], + )?; + let requester = parse_agent_id(&self.requester).map_err(|_| { + application::AppError::Invalid( + "idea_project_ask_agent requires an agent requester handshake".to_owned(), + ) + })?; + self.service + .ask_project_agent_for_mcp( + project_id()?, + agent_id()?, + required_str_arg(args, "task")?.to_owned(), + self.project.id, + requester, + required_str_arg(args, "correlationId")?.to_owned(), + ) + .await + } + "idea_project_cancel_agent" => { + reject_extra_project_args(args, &["projectId", "agentId", "correlationId"])?; + let requester = parse_agent_id(&self.requester).map_err(|_| { + application::AppError::Invalid( + "idea_project_cancel_agent requires an agent requester handshake" + .to_owned(), + ) + })?; + self.service + .cancel_project_agent_for_mcp( + project_id()?, + agent_id()?, + self.project.id, + requester, + required_str_arg(args, "correlationId")?.to_owned(), + ) + .await + } + other => Err(application::AppError::Invalid(format!( + "unknown project tool {other}" + ))), + } + } + fn enforce_tool_policy( &self, policy: &AgentToolPolicy, @@ -935,6 +1034,60 @@ fn parse_ask_agents_requests( Ok(out) } +fn is_project_tool(name: &str) -> bool { + matches!( + name, + "idea_project_list" + | "idea_project_read_context" + | "idea_project_list_agents" + | "idea_project_ask_agent" + | "idea_project_cancel_agent" + ) +} + +fn required_str_arg<'a>( + args: &'a serde_json::Map, + key: &str, +) -> Result<&'a str, application::AppError> { + args.get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| application::AppError::Invalid(format!("missing required field `{key}`"))) +} + +fn reject_extra_project_args( + args: &serde_json::Map, + allowed: &[&str], +) -> Result<(), application::AppError> { + if args + .keys() + .any(|key| !allowed.iter().any(|allowed| allowed == key)) + { + return Err(application::AppError::Invalid( + "unexpected argument for project tool".to_owned(), + )); + } + Ok(()) +} + +fn parse_project_id(value: &str) -> Result { + uuid::Uuid::parse_str(value) + .map(ProjectId::from_uuid) + .map_err(|err| application::AppError::Invalid(format!("invalid projectId: {err}"))) +} + +fn parse_agent_id(value: &str) -> Result { + uuid::Uuid::parse_str(value) + .map(AgentId::from_uuid) + .map_err(|err| application::AppError::Invalid(format!("invalid agentId: {err}"))) +} + +fn requester_party(requester: &str) -> domain::ConversationParty { + parse_agent_id(requester) + .map(domain::ConversationParty::agent) + .unwrap_or(domain::ConversationParty::User) +} + fn is_ticket_policy_mutation_tool(name: &str) -> bool { matches!( name, @@ -969,3 +1122,52 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { } } } + +#[cfg(test)] +mod project_tool_tests { + use super::*; + use serde_json::json; + + fn object(value: Value) -> serde_json::Map { + value.as_object().unwrap().clone() + } + + #[test] + fn project_tool_helpers_parse_project_and_agent_ids() { + let project = "11111111-1111-1111-1111-111111111111"; + let agent = "22222222-2222-2222-2222-222222222222"; + + assert_eq!( + parse_project_id(project).unwrap(), + ProjectId::from_uuid(uuid::Uuid::parse_str(project).unwrap()) + ); + assert_eq!( + parse_agent_id(agent).unwrap(), + AgentId::from_uuid(uuid::Uuid::parse_str(agent).unwrap()) + ); + } + + #[test] + fn project_tool_helpers_require_correlation_and_reject_extra_fields() { + let args = object(json!({ + "projectId": "11111111-1111-1111-1111-111111111111", + "agentId": "22222222-2222-2222-2222-222222222222", + "task": "work" + })); + + let err = required_str_arg(&args, "correlationId").unwrap_err(); + assert_eq!(err.code(), "INVALID"); + + let args = object(json!({ + "projectId": "11111111-1111-1111-1111-111111111111", + "agentId": "22222222-2222-2222-2222-222222222222", + "correlationId": "corr-1", + "task": "work", + "target": "ambiguous name" + })); + let err = + reject_extra_project_args(&args, &["projectId", "agentId", "correlationId", "task"]) + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); + } +} diff --git a/crates/infrastructure/src/orchestrator/mcp/templates.rs b/crates/infrastructure/src/orchestrator/mcp/templates.rs index bc95c974..c339f7b9 100644 --- a/crates/infrastructure/src/orchestrator/mcp/templates.rs +++ b/crates/infrastructure/src/orchestrator/mcp/templates.rs @@ -117,7 +117,8 @@ pub fn catalogue() -> Vec { }, "required": ["allowedTools"], "additionalProperties": false - } + }, + "defaultContactable": { "type": "boolean" } }, "required": ["name", "content", "defaultProfileId"], "additionalProperties": false @@ -151,7 +152,8 @@ pub fn catalogue() -> Vec { }, "required": ["allowedTools"], "additionalProperties": false - } + }, + "defaultContactable": { "type": "boolean" } }, "required": ["templateId"], "additionalProperties": false diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 1a226fa3..a0f37d55 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -45,6 +45,9 @@ pub enum McpToolAccess { /// Canonical read-only MCP tools allowed by default. pub const READ_ONLY_TOOLS: &[&str] = &[ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", "idea_list_agents", "idea_context_read", "idea_memory_read", @@ -62,6 +65,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[ /// Canonical write/action MCP tools denied by default. pub const WRITE_ACTION_TOOLS: &[&str] = &[ + "idea_project_ask_agent", + "idea_project_cancel_agent", "idea_ask_agent", "idea_ask_agents", "idea_run_in_background", @@ -136,7 +141,12 @@ pub enum ToolMapError { pub fn tool_returns_reply(tool: &str) -> bool { matches!( tool, - "idea_ask_agent" + "idea_project_list" + | "idea_project_read_context" + | "idea_project_list_agents" + | "idea_project_ask_agent" + | "idea_project_cancel_agent" + | "idea_ask_agent" | "idea_ask_agents" | "idea_list_agents" | "idea_context_read" @@ -174,6 +184,68 @@ pub fn is_managed_temp_tool(tool: &str) -> bool { #[must_use] pub fn catalogue() -> Vec { let mut tools = vec![ + ToolDef { + name: "idea_project_list", + description: "List known IdeA projects for cross-project agent discovery.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_read_context", + description: "Read a project's global context by stable project id.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["projectId"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_list_agents", + description: "List cross-project contactable agents for a project by stable project id.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" } + }, + "required": ["projectId"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_ask_agent", + description: "Ask a cross-project contactable agent by projectId and agentId and wait for its final answer.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" }, + "agentId": { "type": "string", "format": "uuid" }, + "correlationId": { "type": "string" }, + "task": { "type": "string" } + }, + "required": ["projectId", "agentId", "correlationId", "task"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_project_cancel_agent", + description: "Cancel/stop a cross-project contactable agent by projectId, agentId and correlationId.", + input_schema: json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "format": "uuid" }, + "agentId": { "type": "string", "format": "uuid" }, + "correlationId": { "type": "string" } + }, + "required": ["projectId", "agentId", "correlationId"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_list_agents", description: "List the IdeA agents declared in the project's manifest. Returns the \ @@ -762,6 +834,63 @@ mod tests { assert!(tool_returns_reply("idea_ask_agents")); } + #[test] + fn project_tools_are_classified_and_return_inline_replies() { + for tool in [ + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", + ] { + assert_eq!(tool_access(tool), Some(McpToolAccess::Read)); + assert!( + tool_returns_reply(tool), + "{tool} must return inline JSON/text" + ); + } + for tool in ["idea_project_ask_agent", "idea_project_cancel_agent"] { + assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction)); + assert!( + tool_returns_reply(tool), + "{tool} must return an inline result" + ); + } + } + + #[test] + fn catalogue_advertises_project_tools_with_stable_identity_fields() { + let cat = catalogue(); + let by_name = |name: &str| { + cat.iter() + .find(|tool| tool.name == name) + .unwrap_or_else(|| panic!("catalogue must contain {name}")) + }; + + assert_eq!( + by_name("idea_project_read_context").input_schema["required"], + json!(["projectId"]) + ); + assert_eq!( + by_name("idea_project_list_agents").input_schema["required"], + json!(["projectId"]) + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["required"], + json!(["projectId", "agentId", "correlationId", "task"]) + ); + assert_eq!( + by_name("idea_project_cancel_agent").input_schema["required"], + json!(["projectId", "agentId", "correlationId"]) + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["properties"]["projectId"]["format"], + json!("uuid") + ); + assert_eq!( + by_name("idea_project_ask_agent").input_schema["properties"]["agentId"]["format"], + json!("uuid") + ); + } + #[test] fn run_in_background_maps_to_background_command_with_handshake_owner() { let cmd = map_tool_call( diff --git a/crates/infrastructure/src/store/template.rs b/crates/infrastructure/src/store/template.rs index 0f572aa1..b9847430 100644 --- a/crates/infrastructure/src/store/template.rs +++ b/crates/infrastructure/src/store/template.rs @@ -59,6 +59,8 @@ struct IndexEntry { skip_serializing_if = "Option::is_none" )] default_mcp_tool_policy: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + default_contactable: bool, } /// On-disk shape of `templates/index.json`. @@ -170,6 +172,7 @@ impl FsTemplateStore { default_agent_name: entry.default_agent_name.clone(), default_system_permissions: entry.default_system_permissions.clone(), default_mcp_tool_policy: entry.default_mcp_tool_policy.clone(), + default_contactable: entry.default_contactable, }) } } @@ -220,6 +223,7 @@ impl TemplateStore for FsTemplateStore { default_agent_name: template.default_agent_name.clone(), default_system_permissions: template.default_system_permissions.clone(), default_mcp_tool_policy: template.default_mcp_tool_policy.clone(), + default_contactable: template.default_contactable, }; if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) { *slot = row; diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 29f54669..aa30ee3b 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -109,6 +109,7 @@ impl FakeContexts { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } @@ -725,6 +726,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for expected in [ + // Cross-project agent control tools (#263). + "idea_project_list", + "idea_project_read_context", + "idea_project_list_agents", + "idea_project_ask_agent", + "idea_project_cancel_agent", "idea_list_agents", "idea_ask_agent", "idea_ask_agents", @@ -776,8 +783,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 37, - "exactly the thirty-seven exposed idea_* tools; got {names:?}" + 47, + "exactly the forty-seven exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index a28026fb..1690e364 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -98,6 +98,7 @@ impl FakeContexts { synced_template_version: None, skills: Vec::new(), effort: None, + contactable: false, }); id } diff --git a/crates/infrastructure/tests/template_store.rs b/crates/infrastructure/tests/template_store.rs index 08010676..90528844 100644 --- a/crates/infrastructure/tests/template_store.rs +++ b/crates/infrastructure/tests/template_store.rs @@ -70,6 +70,7 @@ async fn save_then_get_and_list_roundtrip() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); store.save(&t).await.unwrap(); @@ -137,6 +138,7 @@ async fn index_is_camelcase_with_content_hash() { Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), + true, ) .unwrap(); store.save(&template).await.unwrap(); @@ -171,6 +173,10 @@ async fn index_is_camelcase_with_content_hash() { .and_then(|v| v.as_str()), Some("idea_template_read") ); + assert_eq!( + entry.get("defaultContactable").and_then(|v| v.as_bool()), + Some(true) + ); assert!(entry.get("content_hash").is_none(), "no snake_case leak"); }