feat(templates): nom d'agent par défaut + permissions système et MCP outils sur AgentTemplate — #242
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.
This commit is contained in:
@ -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
|
||||
|
||||
@ -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<Option<&'a str>, 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<T>(arguments: &Value, key: &str) -> Result<Option<T>, 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<T>(
|
||||
arguments: &Value,
|
||||
primary_key: &str,
|
||||
legacy_key: &str,
|
||||
) -> Result<Option<T>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user