From d6c80be56ddfd71691954566c8c8e270100d2f1a Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 23 Aug 2026 10:59:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(templates):=20nom=20d'agent=20par=20d?= =?UTF-8?q?=C3=A9faut=20+=20permissions=20syst=C3=A8me=20et=20MCP=20outils?= =?UTF-8?q?=20sur=20AgentTemplate=20=E2=80=94=20#242?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentTemplate étendu (defaultAgentName, systemPermissions, mcpToolPermissions), CreateTemplate/UpdateTemplate/CreateAgentFromTemplate projetés vers l'agent créé, DTO/MCP tools et persistance store complétés. Correctif modèle par défaut: UpdateTemplate ne persistait pas defaultProfileId (profil stale à la création d'agent). Backfill best-effort des templates existants depuis les agents du projet au open_project, overrides explicites uniquement. --- crates/app-tauri/src/commands.rs | 85 +++++++- crates/app-tauri/src/templates.rs | 93 ++++++++- crates/app-tauri/tests/dto_templates.rs | 59 +++++- crates/application/src/template/usecases.rs | 114 +++++++++- crates/application/tests/template_usecases.rs | 194 +++++++++++++++++- crates/backend/src/dto.rs | 133 +++++++++++- crates/backend/src/lib.rs | 16 +- crates/domain/src/template.rs | 50 +++++ .../src/orchestrator/mcp/templates.rs | 48 ++++- crates/infrastructure/src/store/template.rs | 18 ++ crates/infrastructure/tests/template_store.rs | 46 ++++- 11 files changed, 809 insertions(+), 47 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 73dc7d26..baa4bff6 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -24,16 +24,16 @@ use application::{ ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, - ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, - ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, - SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + ReadMemoryIndexInput, ReadProjectContextInput, ReadTemplateInput, RecallMemoryInput, + ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, + ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, + RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, + SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, - UpdateProjectSystemPermissionsInput, UpdateSkillInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateTemplateInput, }; use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; @@ -91,7 +91,7 @@ use crate::embedded_server::{ use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; use crate::stream::TauriChannelSink; -use domain::{DeviceId, SkillRef, SkillScope}; +use domain::{AgentOrigin, DeviceId, SkillRef, SkillScope}; use uuid::Uuid; /// `health` — trivial command validating the full IPC pipeline @@ -492,12 +492,83 @@ pub(crate) async fn open_project_for_adapter( // live-state : les Running sans handle vivant deviennent terminales, les // complétions WakeOwner non livrées repassent dans l'inbox unifiée. let _ = state.reconcile_background_tasks.execute(id).await; + let _ = backfill_agent_templates_from_project(&output.project, state).await; // (Re)start the orchestrator watcher for this project (idempotent, §14.3). state.ensure_orchestrator_watch(&output.project); state.reconcile_claude_run_dirs(&output.project).await; Ok(ProjectDto::from(output)) } +async fn backfill_agent_templates_from_project( + project: &domain::Project, + state: &AppState, +) -> Result<(), AppError> { + let agents = state + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await? + .agents; + let system_permissions = state + .get_project_system_permissions + .execute(GetProjectSystemPermissionsInput { + project: project.clone(), + }) + .await? + .permissions; + let mcp_tool_permissions = state + .read_mcp_tool_permissions + .execute(ReadMcpToolPermissionsInput { + project: project.clone(), + }) + .await? + .permissions; + + for agent in agents { + let AgentOrigin::FromTemplate { template_id, .. } = agent.origin else { + continue; + }; + let template = match state + .read_template + .execute(ReadTemplateInput { template_id }) + .await + { + Ok(out) => out.template, + Err(err) if err.code() == "NOT_FOUND" => continue, + Err(err) => return Err(err), + }; + let default_system_permissions = system_permissions.agent_permissions(agent.id).cloned(); + let default_mcp_tool_policy = mcp_tool_permissions + .agents + .iter() + .find(|entry| entry.agent_id == agent.id) + .map(|entry| entry.policy.clone()); + + if template.default_agent_name.as_deref() == Some(agent.name.as_str()) + && template.default_profile_id == agent.profile_id + && template.default_system_permissions == default_system_permissions + && template.default_mcp_tool_policy == default_mcp_tool_policy + { + continue; + } + + state + .update_template + .execute(UpdateTemplateInput { + template_id, + content: None, + name: None, + default_profile_id: Some(agent.profile_id), + default_agent_name: Some(Some(agent.name)), + default_system_permissions: Some(default_system_permissions), + default_mcp_tool_policy: Some(default_mcp_tool_policy), + }) + .await?; + } + Ok(()) +} + /// `close_project` — persist state and release resources for a project. /// /// # Errors diff --git a/crates/app-tauri/src/templates.rs b/crates/app-tauri/src/templates.rs index 333034f0..4abb5830 100644 --- a/crates/app-tauri/src/templates.rs +++ b/crates/app-tauri/src/templates.rs @@ -7,14 +7,16 @@ use std::sync::Arc; use application::{ CreateTemplate, CreateTemplateInput, DeleteTemplate, DeleteTemplateInput, ListTemplates, - ReadTemplate, ReadTemplateInput, UpdateTemplate, UpdateTemplateInput, + ReadTemplate, ReadTemplateInput, UpdateTemplate, }; use async_trait::async_trait; use domain::{Project, TemplateId}; use infrastructure::{TemplateToolError, TemplateToolProvider}; use serde_json::{json, Value}; -use crate::dto::{parse_profile_id, parse_template_id, TemplateDto, TemplateListDto}; +use crate::dto::{ + parse_profile_id, parse_template_id, TemplateDto, TemplateListDto, UpdateTemplateRequestDto, +}; /// Concrete provider for the public MCP template tools. #[derive(Clone)] @@ -65,18 +67,30 @@ impl TemplateToolProvider for AppTemplateToolProvider { "defaultProfileId", )?) .map_err(dto_tool_error)?, + default_agent_name: optional_string(&arguments, "defaultAgentName")? + .map(str::to_owned), + default_system_permissions: optional_json_value( + &arguments, + "defaultSystemPermissions", + )?, + default_mcp_tool_policy: optional_json_value_alias( + &arguments, + "defaultMcpToolPermissions", + "defaultMcpToolPolicy", + )?, }) .await .map_err(template_error)?; json!(TemplateDto::from(template)) } "idea_template_update" => { + let request: UpdateTemplateRequestDto = + serde_json::from_value(arguments).map_err(|err| { + TemplateToolError::new("invalid", format!("invalid update payload: {err}")) + })?; let template = self .update - .execute(UpdateTemplateInput { - template_id: parse_json_template_id(&arguments)?, - content: required_str(&arguments, "content")?.to_owned(), - }) + .execute(request.into_input().map_err(dto_tool_error)?) .await .map_err(template_error)?; json!(TemplateDto::from(template)) @@ -133,6 +147,51 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Template .ok_or_else(|| TemplateToolError::new("invalid", format!("missing {key}"))) } +fn optional_string<'a>( + arguments: &'a Value, + key: &str, +) -> Result, TemplateToolError> { + match arguments.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_str() + .map(Some) + .ok_or_else(|| TemplateToolError::new("invalid", format!("{key} must be a string"))), + } +} + +fn optional_json_value(arguments: &Value, key: &str) -> Result, TemplateToolError> +where + T: serde::de::DeserializeOwned, +{ + match arguments.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .map_err(|err| TemplateToolError::new("invalid", format!("invalid {key}: {err}"))), + } +} + +fn optional_json_value_alias( + arguments: &Value, + primary_key: &str, + legacy_key: &str, +) -> Result, TemplateToolError> +where + T: serde::de::DeserializeOwned, +{ + let primary = optional_json_value(arguments, primary_key)?; + let legacy = optional_json_value(arguments, legacy_key)?; + match (primary, legacy) { + (Some(value), None) | (None, Some(value)) => Ok(Some(value)), + (None, None) => Ok(None), + (Some(_), Some(_)) => Err(TemplateToolError::new( + "invalid", + format!("{primary_key} and {legacy_key} cannot both be provided"), + )), + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -340,7 +399,7 @@ mod tests { } #[test] - fn template_provider_preserves_content_only_b4_boundary() { + fn template_provider_metadata_updates_preserve_version_without_content_change() { let template = AgentTemplate::new( TemplateId::from_uuid(Uuid::from_u128(7)), "Base", @@ -348,10 +407,22 @@ mod tests { ProfileId::from_uuid(Uuid::from_u128(9)), ) .unwrap(); - let updated = template.with_updated_content(MarkdownDoc::new("v2")); + let updated = template + .with_metadata( + "Renamed".to_owned(), + ProfileId::from_uuid(Uuid::from_u128(10)), + Some("Agent".to_owned()), + None, + None, + ) + .unwrap(); - assert_eq!(updated.name, "Base"); - assert_eq!(updated.default_profile_id, template.default_profile_id); - assert_eq!(updated.version, TemplateVersion(2)); + assert_eq!(updated.name, "Renamed"); + assert_eq!( + updated.default_profile_id, + ProfileId::from_uuid(Uuid::from_u128(10)) + ); + assert_eq!(updated.default_agent_name.as_deref(), Some("Agent")); + assert_eq!(updated.version, TemplateVersion::INITIAL); } } diff --git a/crates/app-tauri/tests/dto_templates.rs b/crates/app-tauri/tests/dto_templates.rs index 626a85e4..3e93b00d 100644 --- a/crates/app-tauri/tests/dto_templates.rs +++ b/crates/app-tauri/tests/dto_templates.rs @@ -15,6 +15,7 @@ use application::{ use domain::ids::{AgentId, ProfileId, TemplateId}; use domain::markdown::MarkdownDoc; use domain::template::{AgentTemplate, TemplateVersion}; +use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet}; use serde_json::json; use uuid::Uuid; @@ -52,6 +53,10 @@ fn template_dto_serialises_camelcase() { v["defaultProfileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ); + assert!(v.get("defaultAgentName").is_none()); + assert!(v.get("defaultSystemPermissions").is_none()); + assert!(v.get("defaultMcpToolPermissions").is_none()); + assert!(v.get("defaultMcpToolPolicy").is_none()); // no snake_case leak assert!( v.get("content_md").is_none(), @@ -220,6 +225,34 @@ fn create_template_request_deserialises_camelcase() { assert_eq!(dto.name, "Backend Template"); assert_eq!(dto.content, "# My context"); assert_eq!(dto.default_profile_id, profile_id); + assert!(dto.default_agent_name.is_none()); + assert!(dto.default_system_permissions.is_none()); + assert!(dto.default_mcp_tool_policy.is_none()); +} + +#[test] +fn create_template_request_deserialises_template_defaults() { + let profile_id = Uuid::from_u128(21).to_string(); + let raw = json!({ + "name": "Backend Template", + "content": "# My context", + "defaultProfileId": profile_id, + "defaultAgentName": "Backend", + "defaultSystemPermissions": { "network": "allow" }, + "defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] } + }); + let dto: CreateTemplateRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.default_agent_name.as_deref(), Some("Backend")); + assert_eq!( + dto.default_system_permissions, + Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))) + ); + assert_eq!( + dto.default_mcp_tool_policy, + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()] + }) + ); } #[test] @@ -231,7 +264,31 @@ fn update_template_request_deserialises_camelcase() { }); let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap(); assert_eq!(dto.template_id, template_id); - assert_eq!(dto.content, "# Updated content"); + assert_eq!(dto.content.as_deref(), Some("# Updated content")); + assert!(dto.default_profile_id.is_none()); +} + +#[test] +fn update_template_request_deserialises_metadata_and_nullable_clears() { + let template_id = Uuid::from_u128(31).to_string(); + let profile_id = Uuid::from_u128(32).to_string(); + let raw = json!({ + "templateId": template_id, + "name": "Renamed", + "defaultProfileId": profile_id, + "defaultAgentName": null, + "defaultSystemPermissions": { "network": "deny" }, + "defaultMcpToolPermissions": null + }); + let dto: UpdateTemplateRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.name.as_deref(), Some("Renamed")); + assert_eq!(dto.default_profile_id.as_deref(), Some(profile_id.as_str())); + assert_eq!(dto.default_agent_name, Some(None)); + assert_eq!( + dto.default_system_permissions, + Some(Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny)))) + ); + assert_eq!(dto.default_mcp_tool_policy, Some(None)); } #[test] diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index 7e3805b9..62e2b6ea 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -13,10 +13,14 @@ use std::sync::Arc; -use domain::ports::{AgentContextStore, EventBus, IdGenerator, StoreError, TemplateStore}; +use domain::ports::{ + AgentContextStore, EventBus, IdGenerator, McpToolPermissionStore, StoreError, + SystemPermissionStore, TemplateStore, +}; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentTemplate, DomainEvent, ManifestEntry, - MarkdownDoc, ProfileId, Project, TemplateId, TemplateVersion, + MarkdownDoc, McpToolPolicy, ProfileId, Project, SystemPermissionSet, TemplateId, + TemplateVersion, }; use crate::agent::unique_md_path; @@ -35,6 +39,12 @@ pub struct CreateTemplateInput { pub content: String, /// Default runtime profile for agents created from this template. pub default_profile_id: ProfileId, + /// Optional default agent name for agents created from this template. + pub default_agent_name: Option, + /// Optional agent-level system permission override applied at creation time. + pub default_system_permissions: Option, + /// Optional agent-level MCP tool permission override applied at creation time. + pub default_mcp_tool_policy: Option, } /// Output of [`CreateTemplate::execute`]. @@ -69,10 +79,19 @@ impl CreateTemplate { let id = TemplateId::from_uuid(self.ids.new_uuid()); let template = AgentTemplate::new( id, - input.name, + input.name.clone(), MarkdownDoc::new(input.content), input.default_profile_id, ) + .and_then(|template| { + template.with_metadata( + input.name, + input.default_profile_id, + input.default_agent_name, + input.default_system_permissions, + input.default_mcp_tool_policy, + ) + }) .map_err(|e| AppError::Invalid(e.to_string()))?; self.templates.save(&template).await?; Ok(CreateTemplateOutput { template }) @@ -88,8 +107,21 @@ impl CreateTemplate { pub struct UpdateTemplateInput { /// Template to update. pub template_id: TemplateId, - /// New Markdown content. - pub content: String, + /// New Markdown content. `None` leaves content and version untouched. + pub content: Option, + /// New display name. `None` leaves the current value. + pub name: Option, + /// New default runtime profile. `None` leaves the current value. + pub default_profile_id: Option, + /// New default agent name. Outer `None` leaves the current value; inner + /// `None` clears the default so agent creation falls back to template name. + pub default_agent_name: Option>, + /// New default system permissions. Outer `None` leaves the current value; + /// inner `None` preserves project inheritance for future agents. + pub default_system_permissions: Option>, + /// 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>, } /// Output of [`UpdateTemplate::execute`]. @@ -123,7 +155,28 @@ impl UpdateTemplate { input: UpdateTemplateInput, ) -> Result { let current = self.templates.get(input.template_id).await?; - let updated = current.with_updated_content(MarkdownDoc::new(input.content)); + let mut updated = if let Some(content) = input.content { + current.with_updated_content(MarkdownDoc::new(content)) + } else { + current + }; + updated = updated + .with_metadata( + input.name.unwrap_or_else(|| updated.name.clone()), + input + .default_profile_id + .unwrap_or(updated.default_profile_id), + input + .default_agent_name + .unwrap_or_else(|| updated.default_agent_name.clone()), + input + .default_system_permissions + .unwrap_or_else(|| updated.default_system_permissions.clone()), + input + .default_mcp_tool_policy + .unwrap_or_else(|| updated.default_mcp_tool_policy.clone()), + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; self.templates.save(&updated).await?; self.events.publish(DomainEvent::TemplateUpdated { template_id: updated.id, @@ -271,6 +324,8 @@ pub struct CreateAgentFromTemplate { contexts: Arc, ids: Arc, events: Arc, + system_permissions: Option>, + mcp_tool_permissions: Option>, } impl CreateAgentFromTemplate { @@ -287,9 +342,30 @@ impl CreateAgentFromTemplate { contexts, ids, events, + system_permissions: None, + mcp_tool_permissions: None, } } + /// Injects the project system permission store used to project template + /// agent overrides at creation time. + #[must_use] + pub fn with_system_permission_store(mut self, store: Arc) -> Self { + self.system_permissions = Some(store); + self + } + + /// Injects the project MCP tool permission store used to project template + /// agent overrides at creation time. + #[must_use] + pub fn with_mcp_tool_permission_store( + mut self, + store: Arc, + ) -> Self { + self.mcp_tool_permissions = Some(store); + self + } + /// Executes creation from a template. /// /// # Errors @@ -303,7 +379,10 @@ impl CreateAgentFromTemplate { let template = self.templates.get(input.template_id).await?; let manifest = self.contexts.load_manifest(&input.project).await?; - let name = input.name.unwrap_or_else(|| template.name.clone()); + let name = input + .name + .or_else(|| template.default_agent_name.clone()) + .unwrap_or_else(|| template.name.clone()); let id = AgentId::from_uuid(self.ids.new_uuid()); let md_path = unique_md_path(&name, &manifest); let origin = AgentOrigin::FromTemplate { @@ -333,6 +412,27 @@ impl CreateAgentFromTemplate { .write_context(&input.project, &agent.id, &template.content_md) .await?; + if let Some(permissions) = template.default_system_permissions.clone() { + if let Some(store) = &self.system_permissions { + let mut doc = store.load_system_permissions(&input.project).await?; + doc.set_agent_permissions(agent.id, Some(permissions)); + store.save_system_permissions(&input.project, &doc).await?; + } + } + + if let Some(policy) = template.default_mcp_tool_policy.clone() { + if let Some(store) = &self.mcp_tool_permissions { + let mut doc = store.load_mcp_tool_permissions(&input.project).await?; + doc.agents + .retain(|override_| override_.agent_id != agent.id); + doc.agents + .push(domain::AgentMcpToolPolicyOverride::new(agent.id, policy)); + store + .save_mcp_tool_permissions(&input.project, &doc) + .await?; + } + } + self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index ca00c4fb..ac804503 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -10,10 +10,15 @@ use domain::events::DomainEvent; use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId}; use domain::markdown::MarkdownDoc; use domain::ports::{ - AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore, + AgentContextStore, EventBus, EventStream, IdGenerator, McpToolPermissionStore, StoreError, + SystemPermissionStore, TemplateStore, }; use domain::template::{AgentTemplate, TemplateVersion}; -use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef}; +use domain::{ + AgentManifest, AgentMcpToolPolicyOverride, AgentSystemPermissionOverride, ManifestEntry, + McpToolPolicy, NetworkPolicy, Project, ProjectMcpToolPermissions, ProjectPath, + ProjectSystemPermissions, RemoteRef, SystemPermissionSet, +}; use uuid::Uuid; use application::{ @@ -147,6 +152,62 @@ impl SeqIds { Self(Mutex::new(1)) } } + +#[derive(Clone)] +struct FakeSystemPermissions(Arc>); + +impl Default for FakeSystemPermissions { + fn default() -> Self { + Self(Arc::new(Mutex::new(ProjectSystemPermissions::default()))) + } +} + +#[async_trait] +impl SystemPermissionStore for FakeSystemPermissions { + async fn load_system_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + + async fn save_system_permissions( + &self, + _project: &Project, + permissions: &ProjectSystemPermissions, + ) -> Result<(), StoreError> { + *self.0.lock().unwrap() = permissions.clone(); + Ok(()) + } +} + +#[derive(Clone)] +struct FakeMcpToolPermissions(Arc>); + +impl Default for FakeMcpToolPermissions { + fn default() -> Self { + Self(Arc::new(Mutex::new(ProjectMcpToolPermissions::default()))) + } +} + +#[async_trait] +impl McpToolPermissionStore for FakeMcpToolPermissions { + async fn load_mcp_tool_permissions( + &self, + _project: &Project, + ) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + + async fn save_mcp_tool_permissions( + &self, + _project: &Project, + permissions: &ProjectMcpToolPermissions, + ) -> Result<(), StoreError> { + *self.0.lock().unwrap() = permissions.clone(); + Ok(()) + } +} impl IdGenerator for SeqIds { fn new_uuid(&self) -> Uuid { let mut n = self.0.lock().unwrap(); @@ -216,6 +277,9 @@ async fn create_template_starts_at_initial_version() { name: "Backend".to_owned(), content: "# ctx".to_owned(), default_profile_id: pid(7), + default_agent_name: None, + default_system_permissions: None, + default_mcp_tool_policy: None, }) .await .unwrap(); @@ -254,7 +318,12 @@ async fn update_template_bumps_version_and_publishes_event() { let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(UpdateTemplateInput { template_id: tid(1), - content: "v2".to_owned(), + content: Some("v2".to_owned()), + name: None, + default_profile_id: None, + default_agent_name: None, + default_system_permissions: None, + default_mcp_tool_policy: None, }) .await .unwrap(); @@ -278,13 +347,74 @@ async fn update_unknown_template_is_not_found() { ) .execute(UpdateTemplateInput { template_id: tid(404), - content: "x".to_owned(), + content: Some("x".to_owned()), + name: None, + default_profile_id: None, + default_agent_name: None, + default_system_permissions: None, + default_mcp_tool_policy: None, }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } +#[tokio::test] +async fn update_template_updates_default_profile_for_future_agents() { + let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]); + + let updated = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(SpyBus::default())) + .execute(UpdateTemplateInput { + template_id: tid(1), + content: Some("v2".to_owned()), + name: Some("Renamed".to_owned()), + default_profile_id: Some(pid(42)), + default_agent_name: Some(Some("Agent Name".to_owned())), + default_system_permissions: Some(Some(SystemPermissionSet::new(Some( + NetworkPolicy::Deny, + )))), + default_mcp_tool_policy: Some(Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + })), + }) + .await + .unwrap() + .template; + + assert_eq!(updated.name, "Renamed"); + assert_eq!(updated.default_profile_id, pid(42)); + assert_eq!(updated.default_agent_name.as_deref(), Some("Agent Name")); + assert_eq!( + updated.default_system_permissions, + Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))) + ); + assert_eq!( + updated.default_mcp_tool_policy, + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()] + }) + ); + + let contexts = FakeContexts::new(vec![]); + let out = CreateAgentFromTemplate::new( + Arc::new(store), + Arc::new(contexts), + Arc::new(SeqIds::new()), + Arc::new(SpyBus::default()), + ) + .execute(CreateAgentFromTemplateInput { + project: project(), + template_id: tid(1), + name: None, + synchronized: true, + }) + .await + .unwrap(); + + assert_eq!(out.agent.name, "Agent Name"); + assert_eq!(out.agent.profile_id, pid(42)); +} + // --------------------------------------------------------------------------- // CreateAgentFromTemplate // --------------------------------------------------------------------------- @@ -327,6 +457,62 @@ async fn create_agent_from_template_links_origin_and_seeds_context() { assert_eq!(contexts.manifest().entries.len(), 1); } +#[tokio::test] +async fn create_agent_from_template_projects_template_permission_overrides() { + let mut template = template(tid(1), "Backend Template", "# body", 1); + template = template + .with_metadata( + "Backend Template".to_owned(), + pid(7), + Some("Backend".to_owned()), + Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))), + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + }), + ) + .unwrap(); + let store = FakeTemplates::with(vec![template]); + let contexts = FakeContexts::new(vec![]); + let system_permissions = FakeSystemPermissions::default(); + let mcp_tool_permissions = FakeMcpToolPermissions::default(); + + let out = CreateAgentFromTemplate::new( + Arc::new(store), + Arc::new(contexts), + Arc::new(SeqIds::new()), + Arc::new(SpyBus::default()), + ) + .with_system_permission_store(Arc::new(system_permissions.clone())) + .with_mcp_tool_permission_store(Arc::new(mcp_tool_permissions.clone())) + .execute(CreateAgentFromTemplateInput { + project: project(), + template_id: tid(1), + name: None, + synchronized: true, + }) + .await + .unwrap(); + + assert_eq!(out.agent.name, "Backend"); + assert_eq!(out.agent.profile_id, pid(7)); + assert_eq!( + system_permissions.0.lock().unwrap().agents, + vec![AgentSystemPermissionOverride::new( + out.agent.id, + SystemPermissionSet::new(Some(NetworkPolicy::Allow)) + )] + ); + assert_eq!( + mcp_tool_permissions.0.lock().unwrap().agents, + vec![AgentMcpToolPolicyOverride::new( + out.agent.id, + McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()] + } + )] + ); +} + // --------------------------------------------------------------------------- // DetectAgentDrift // --------------------------------------------------------------------------- diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index fb43fe31..ffaebb88 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -6,6 +6,7 @@ //! TypeScript side sees idiomatic camelCase. This matches the persisted-domain //! JSON convention already used in the domain (`agents.json` etc.). +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -4289,6 +4290,19 @@ pub struct CreateTemplateRequestDto { pub content: String, /// Default runtime profile id for agents created from this template. pub default_profile_id: String, + /// Optional default agent name for agents created from this template. + #[serde(default)] + pub default_agent_name: Option, + /// Optional agent-level system permission override for created agents. + #[serde(default)] + pub default_system_permissions: Option, + /// Optional agent-level MCP tool policy override for created agents. + #[serde( + default, + rename = "defaultMcpToolPermissions", + alias = "defaultMcpToolPolicy" + )] + pub default_mcp_tool_policy: Option, } impl CreateTemplateRequestDto { @@ -4301,18 +4315,73 @@ impl CreateTemplateRequestDto { name: self.name, content: self.content, default_profile_id: parse_profile_id(&self.default_profile_id)?, + default_agent_name: self.default_agent_name, + default_system_permissions: self.default_system_permissions, + default_mcp_tool_policy: self.default_mcp_tool_policy, }) } } /// Request DTO for `update_template`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone)] pub struct UpdateTemplateRequestDto { /// Id of the template to update. pub template_id: String, - /// New Markdown content. - pub content: String, + /// New Markdown content. Missing/null leaves it unchanged. + pub content: Option, + /// New display name. Missing leaves it unchanged. + pub name: Option, + /// New default runtime profile. Missing leaves it unchanged. + pub default_profile_id: Option, + /// New default agent name. Missing leaves it unchanged; null clears it. + pub default_agent_name: Option>, + /// New default system permissions. Missing leaves it unchanged; null clears it. + pub default_system_permissions: Option>, + /// New default MCP tool policy. Missing leaves it unchanged; null clears it. + pub default_mcp_tool_policy: Option>, +} + +impl<'de> Deserialize<'de> for UpdateTemplateRequestDto { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut value = Value::deserialize(deserializer)?; + let object = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("expected update_template object"))?; + let template_id = + take_required_string(object, "templateId").map_err(serde::de::Error::custom)?; + let content = take_optional_value::(object, "content") + .map_err(serde::de::Error::custom)? + .flatten(); + let name = take_optional_value::(object, "name") + .map_err(serde::de::Error::custom)? + .flatten(); + let default_profile_id = take_optional_value::(object, "defaultProfileId") + .map_err(serde::de::Error::custom)? + .flatten(); + let default_agent_name = take_optional_value::(object, "defaultAgentName") + .map_err(serde::de::Error::custom)?; + let default_system_permissions = + take_optional_value::(object, "defaultSystemPermissions") + .map_err(serde::de::Error::custom)?; + let default_mcp_tool_policy = take_optional_value_alias::( + object, + "defaultMcpToolPermissions", + "defaultMcpToolPolicy", + ) + .map_err(serde::de::Error::custom)?; + Ok(Self { + template_id, + content, + name, + default_profile_id, + default_agent_name, + default_system_permissions, + default_mcp_tool_policy, + }) + } } impl UpdateTemplateRequestDto { @@ -4324,10 +4393,66 @@ impl UpdateTemplateRequestDto { Ok(UpdateTemplateInput { template_id: parse_template_id(&self.template_id)?, content: self.content, + name: self.name, + default_profile_id: self + .default_profile_id + .as_deref() + .map(parse_profile_id) + .transpose()?, + default_agent_name: self.default_agent_name, + default_system_permissions: self.default_system_permissions, + default_mcp_tool_policy: self.default_mcp_tool_policy, }) } } +fn take_required_string( + object: &mut serde_json::Map, + key: &str, +) -> Result { + match object.remove(key) { + Some(Value::String(value)) => Ok(value), + Some(_) => Err(format!("{key} must be a string")), + None => Err(format!("missing {key}")), + } +} + +fn take_optional_value( + object: &mut serde_json::Map, + key: &str, +) -> Result>, String> +where + T: DeserializeOwned, +{ + match object.remove(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(value) => serde_json::from_value(value) + .map(Some) + .map(Some) + .map_err(|err| format!("invalid {key}: {err}")), + } +} + +fn take_optional_value_alias( + object: &mut serde_json::Map, + primary_key: &str, + legacy_key: &str, +) -> Result>, String> +where + T: DeserializeOwned, +{ + let primary = take_optional_value(object, primary_key)?; + let legacy = take_optional_value(object, legacy_key)?; + match (primary, legacy) { + (Some(value), None) | (None, Some(value)) => Ok(Some(value)), + (None, None) => Ok(None), + (Some(_), Some(_)) => Err(format!( + "{primary_key} and {legacy_key} cannot both be provided" + )), + } +} + /// Parses a template-id string (UUID) coming from the frontend. /// /// # Errors diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index a0ebb467..469f46b9 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -2392,12 +2392,16 @@ impl BackendCore { )); let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port))); let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port))); - let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new( - Arc::clone(&template_store_port), - Arc::clone(&contexts_port), - Arc::clone(&ids) as Arc, - Arc::clone(&events_port), - )); + let create_agent_from_template = Arc::new( + CreateAgentFromTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + ) + .with_system_permission_store(Arc::clone(&system_permission_store_port)) + .with_mcp_tool_permission_store(Arc::clone(&mcp_tool_permission_store_port)), + ); let detect_agent_drift = Arc::new(DetectAgentDrift::new( Arc::clone(&template_store_port), Arc::clone(&contexts_port), diff --git a/crates/domain/src/template.rs b/crates/domain/src/template.rs index 588625e1..0b9ea5c9 100644 --- a/crates/domain/src/template.rs +++ b/crates/domain/src/template.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::{ProfileId, TemplateId}; use crate::markdown::MarkdownDoc; +use crate::mcp_tool_permissions::McpToolPolicy; +use crate::system_permissions::SystemPermissionSet; /// Monotonically increasing template version. /// @@ -55,6 +57,23 @@ pub struct AgentTemplate { pub version: TemplateVersion, /// Default runtime profile for agents created from this template. pub default_profile_id: ProfileId, + /// Default agent name for agents created from this template. `None` falls + /// back to the template display name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_agent_name: Option, + /// Agent-level system permission override to apply at creation time. + /// `None` preserves project inheritance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_system_permissions: Option, + /// Agent-level MCP tool policy override to apply at creation time. + /// `None` preserves project inheritance. + #[serde( + default, + rename = "defaultMcpToolPermissions", + alias = "defaultMcpToolPolicy", + skip_serializing_if = "Option::is_none" + )] + pub default_mcp_tool_policy: Option, } impl AgentTemplate { @@ -76,6 +95,9 @@ impl AgentTemplate { content_md, version: TemplateVersion::INITIAL, default_profile_id, + default_agent_name: None, + default_system_permissions: None, + default_mcp_tool_policy: None, }) } @@ -89,4 +111,32 @@ impl AgentTemplate { ..self.clone() } } + + /// Returns a copy with template metadata changed. The template version is + /// left unchanged because these defaults affect future agent creation only. + /// + /// # Errors + /// Returns [`DomainError::EmptyField`] if `name` or a provided default agent + /// name is empty. + pub fn with_metadata( + &self, + name: String, + default_profile_id: ProfileId, + default_agent_name: Option, + default_system_permissions: Option, + default_mcp_tool_policy: Option, + ) -> Result { + crate::validation::non_empty(&name, "template.name")?; + if let Some(default_agent_name) = &default_agent_name { + crate::validation::non_empty(default_agent_name, "template.defaultAgentName")?; + } + Ok(Self { + name, + default_profile_id, + default_agent_name, + default_system_permissions, + default_mcp_tool_policy, + ..self.clone() + }) + } } diff --git a/crates/infrastructure/src/orchestrator/mcp/templates.rs b/crates/infrastructure/src/orchestrator/mcp/templates.rs index d28b75b0..bc95c974 100644 --- a/crates/infrastructure/src/orchestrator/mcp/templates.rs +++ b/crates/infrastructure/src/orchestrator/mcp/templates.rs @@ -98,7 +98,26 @@ pub fn catalogue() -> Vec { "properties": { "name": { "type": "string" }, "content": { "type": "string" }, - "defaultProfileId": { "type": "string", "format": "uuid" } + "defaultProfileId": { "type": "string", "format": "uuid" }, + "defaultAgentName": { "type": ["string", "null"] }, + "defaultSystemPermissions": { + "type": ["object", "null"], + "properties": { + "network": { "type": "string", "enum": ["allow", "deny", "ask"] } + }, + "additionalProperties": false + }, + "defaultMcpToolPermissions": { + "type": ["object", "null"], + "properties": { + "allowedTools": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["allowedTools"], + "additionalProperties": false + } }, "required": ["name", "content", "defaultProfileId"], "additionalProperties": false @@ -106,14 +125,35 @@ pub fn catalogue() -> Vec { }, ToolDef { name: "idea_template_update", - description: "Update a global IdeA agent template's Markdown content.", + description: "Update a global IdeA agent template.", input_schema: json!({ "type": "object", "properties": { "templateId": template_id.clone(), - "content": { "type": "string" } + "content": { "type": ["string", "null"] }, + "name": { "type": "string" }, + "defaultProfileId": { "type": "string", "format": "uuid" }, + "defaultAgentName": { "type": ["string", "null"] }, + "defaultSystemPermissions": { + "type": ["object", "null"], + "properties": { + "network": { "type": "string", "enum": ["allow", "deny", "ask"] } + }, + "additionalProperties": false + }, + "defaultMcpToolPermissions": { + "type": ["object", "null"], + "properties": { + "allowedTools": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["allowedTools"], + "additionalProperties": false + } }, - "required": ["templateId", "content"], + "required": ["templateId"], "additionalProperties": false }), }, diff --git a/crates/infrastructure/src/store/template.rs b/crates/infrastructure/src/store/template.rs index 662856be..0f572aa1 100644 --- a/crates/infrastructure/src/store/template.rs +++ b/crates/infrastructure/src/store/template.rs @@ -28,6 +28,7 @@ use domain::ids::{ProfileId, TemplateId}; use domain::markdown::MarkdownDoc; use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; use domain::template::{AgentTemplate, TemplateVersion}; +use domain::{McpToolPolicy, SystemPermissionSet}; /// Directory (under app-data) holding the templates store. const TEMPLATES_DIR: &str = "templates"; @@ -47,6 +48,17 @@ struct IndexEntry { version: TemplateVersion, content_hash: String, default_profile_id: ProfileId, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_agent_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_system_permissions: Option, + #[serde( + default, + rename = "defaultMcpToolPermissions", + alias = "defaultMcpToolPolicy", + skip_serializing_if = "Option::is_none" + )] + default_mcp_tool_policy: Option, } /// On-disk shape of `templates/index.json`. @@ -155,6 +167,9 @@ impl FsTemplateStore { content_md: MarkdownDoc::new(content), version: entry.version, default_profile_id: entry.default_profile_id, + 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(), }) } } @@ -202,6 +217,9 @@ impl TemplateStore for FsTemplateStore { version: template.version, content_hash: content_hash(&template.content_md), default_profile_id: template.default_profile_id, + 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(), }; if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) { *slot = row; diff --git a/crates/infrastructure/tests/template_store.rs b/crates/infrastructure/tests/template_store.rs index f8862cee..08010676 100644 --- a/crates/infrastructure/tests/template_store.rs +++ b/crates/infrastructure/tests/template_store.rs @@ -9,6 +9,7 @@ use domain::ids::{ProfileId, TemplateId}; use domain::markdown::MarkdownDoc; use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore}; use domain::template::AgentTemplate; +use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet}; use infrastructure::{FsTemplateStore, LocalFileSystem}; use uuid::Uuid; @@ -60,6 +61,17 @@ async fn save_then_get_and_list_roundtrip() { let store = store(&tmp); let t = template(tid(1), "Backend", "# Backend template"); + let t = t + .with_metadata( + "Backend".to_owned(), + pid(2), + Some("Backend Agent".to_owned()), + Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))), + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + }), + ) + .unwrap(); store.save(&t).await.unwrap(); assert_eq!(store.get(tid(1)).await.unwrap(), t); @@ -116,10 +128,18 @@ async fn delete_removes_from_index() { async fn index_is_camelcase_with_content_hash() { let tmp = TempDir::new(); let store = store(&tmp); - store - .save(&template(tid(1), "Backend", "hello")) - .await + let template = template(tid(1), "Backend", "hello") + .with_metadata( + "Backend".to_owned(), + pid(2), + Some("Backend Agent".to_owned()), + Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))), + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + }), + ) .unwrap(); + store.save(&template).await.unwrap(); let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap(); @@ -131,6 +151,26 @@ async fn index_is_camelcase_with_content_hash() { "camelCase contentHash present" ); assert!(entry.get("defaultProfileId").is_some()); + assert_eq!( + entry.get("defaultAgentName").and_then(|v| v.as_str()), + Some("Backend Agent") + ); + assert_eq!( + entry + .get("defaultSystemPermissions") + .and_then(|v| v.get("network")) + .and_then(|v| v.as_str()), + Some("allow") + ); + assert_eq!( + entry + .get("defaultMcpToolPermissions") + .and_then(|v| v.get("allowedTools")) + .and_then(|v| v.as_array()) + .and_then(|values| values.first()) + .and_then(|v| v.as_str()), + Some("idea_template_read") + ); assert!(entry.get("content_hash").is_none(), "no snake_case leak"); } From f95280788218613387348a1bd8d4c96af07f3e5f Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 23 Aug 2026 11:00:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(ui):=20=C3=A9dition=20templates=20?= =?UTF-8?q?=E2=80=94=20nom=20d'agent=20par=20d=C3=A9faut,=20permissions=20?= =?UTF-8?q?syst=C3=A8me=20et=20MCP=20outils=20=E2=80=94=20#242?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateEditor/TemplatesPanel étendus aux nouveaux champs, projection useTemplates/adapters/ports, panneau Agents aligné, templatePresentation.ts extrait. Tests frontend étendus (templates, agents, routage chat WS). --- .../adapters/http/requestResponseGateways.ts | 24 +- .../adapters/http/wsLiveClient.chat.test.ts | 4 +- frontend/src/adapters/mock/index.ts | 34 +- frontend/src/adapters/template.ts | 17 +- frontend/src/domain/index.ts | 3 + frontend/src/features/agents/AgentsPanel.tsx | 42 +- frontend/src/features/agents/agents.test.tsx | 32 ++ .../src/features/templates/TemplateEditor.tsx | 364 +++++++++++------- .../src/features/templates/TemplatesPanel.tsx | 60 ++- .../templates/templatePresentation.ts | 53 +++ .../src/features/templates/templates.test.tsx | 65 ++++ .../src/features/templates/useTemplates.ts | 10 +- frontend/src/ports/index.ts | 17 +- 13 files changed, 551 insertions(+), 174 deletions(-) create mode 100644 frontend/src/features/templates/templatePresentation.ts diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 71f38e76..aead477c 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -60,6 +60,7 @@ import type { CreateMemoryInput, CreateSkillInput, CreateTemplateInput, + UpdateTemplateInput, EmbedderGateway, GitGateway, InputGateway, @@ -286,11 +287,28 @@ export class HttpTemplateGateway implements TemplateGateway { } createTemplate(input: CreateTemplateInput): Promise