Merge feature/ticket81-mcp-templates-editing into develop
Ticket #81 : MCP d'édition de templates — catalogue/classification (B1), use cases/provider (B2), enforcement policy (B3). QA vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -25,6 +25,7 @@ pub mod pty;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
pub mod templates;
|
||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ use domain::ports::{
|
|||||||
ToolSpec,
|
ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::TicketToolProvider;
|
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||||
@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker {
|
|||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
orchestrator,
|
orchestrator,
|
||||||
@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker {
|
|||||||
policies,
|
policies,
|
||||||
mcp_tool_permissions,
|
mcp_tool_permissions,
|
||||||
ticket_tools,
|
ticket_tools,
|
||||||
|
template_tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
return serde_json::to_string(&value)
|
return serde_json::to_string(&value)
|
||||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}")));
|
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}")));
|
||||||
}
|
}
|
||||||
|
if infrastructure::orchestrator::mcp::tools::is_template_tool(name) {
|
||||||
|
let value = self
|
||||||
|
.template_tools
|
||||||
|
.handle_template_tool(&project, &requester, name, value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let detail =
|
||||||
|
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
|
||||||
|
ToolInvocationError::Execution(detail)
|
||||||
|
})?;
|
||||||
|
return serde_json::to_string(&value)
|
||||||
|
.map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}")));
|
||||||
|
}
|
||||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||||
@ -312,7 +328,7 @@ mod tests {
|
|||||||
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||||
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||||
};
|
};
|
||||||
use infrastructure::TicketToolError;
|
use infrastructure::{TemplateToolError, TicketToolError};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -436,6 +452,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeTemplateTools {
|
||||||
|
calls: Mutex<Vec<(String, String, Value)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateToolProvider for FakeTemplateTools {
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, TemplateToolError> {
|
||||||
|
self.calls.lock().unwrap().push((
|
||||||
|
requester.to_owned(),
|
||||||
|
name.to_owned(),
|
||||||
|
arguments.clone(),
|
||||||
|
));
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"requester": requester,
|
||||||
|
"templateId": arguments.get("templateId").and_then(Value::as_str).unwrap_or_default(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn project() -> Project {
|
fn project() -> Project {
|
||||||
Project::new(
|
Project::new(
|
||||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||||
@ -484,17 +527,20 @@ mod tests {
|
|||||||
let core = backend::BackendCore::build(temp.clone());
|
let core = backend::BackendCore::build(temp.clone());
|
||||||
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
Arc::new(FakePolicies::default()),
|
Arc::new(FakePolicies::default()),
|
||||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for tool in ["idea_ticket_list", "idea_template_list"] {
|
||||||
invoker
|
invoker
|
||||||
.call(
|
.call(
|
||||||
"idea_ticket_list",
|
tool,
|
||||||
&json!({
|
&json!({
|
||||||
PROJECT_ROOT_ARG: "/tmp/project",
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
REQUESTER_ARG: requester,
|
REQUESTER_ARG: requester,
|
||||||
@ -503,6 +549,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("read tool must pass the durable read-only policy");
|
.expect("read tool must pass the durable read-only policy");
|
||||||
|
}
|
||||||
|
|
||||||
for (tool, arguments) in [
|
for (tool, arguments) in [
|
||||||
(
|
(
|
||||||
@ -521,6 +568,18 @@ mod tests {
|
|||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
json!({ "label": "task", "command": "echo" }),
|
json!({ "label": "task", "command": "echo" }),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"idea_template_create",
|
||||||
|
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_template_update",
|
||||||
|
json!({ "templateId": Uuid::new_v4(), "content": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_template_delete",
|
||||||
|
json!({ "templateId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let mut payload = arguments.as_object().unwrap().clone();
|
let mut payload = arguments.as_object().unwrap().clone();
|
||||||
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
||||||
@ -538,6 +597,13 @@ mod tests {
|
|||||||
let calls = ticket_tools.calls.lock().unwrap();
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
||||||
assert_eq!(calls[0].1, "idea_ticket_list");
|
assert_eq!(calls[0].1, "idea_ticket_list");
|
||||||
|
let template_calls = template_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
template_calls.len(),
|
||||||
|
1,
|
||||||
|
"only the read template tool should run"
|
||||||
|
);
|
||||||
|
assert_eq!(template_calls[0].1, "idea_template_list");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
@ -552,12 +618,14 @@ mod tests {
|
|||||||
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
let requester = agent.to_string();
|
let requester = agent.to_string();
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
Arc::new(FakePolicies::default()),
|
Arc::new(FakePolicies::default()),
|
||||||
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools,
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = invoker
|
let result = invoker
|
||||||
@ -585,6 +653,73 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_durable_agent_override_allows_only_explicit_template_write_tool() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-app-tauri-openai-template-permissions-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = backend::BackendCore::build(temp.clone());
|
||||||
|
let agent = AgentId::from_uuid(Uuid::from_u128(84));
|
||||||
|
let requester = agent.to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
|
let template_id = Uuid::from_u128(7).to_string();
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(allow_doc(agent, &["idea_template_update"])),
|
||||||
|
ticket_tools,
|
||||||
|
template_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = invoker
|
||||||
|
.call(
|
||||||
|
"idea_template_update",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"templateId": template_id,
|
||||||
|
"content": "body",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("explicit durable override should reach template provider");
|
||||||
|
let result: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(result["requester"], requester);
|
||||||
|
assert_eq!(result["templateId"], template_id);
|
||||||
|
|
||||||
|
for tool in ["idea_template_create", "idea_template_delete"] {
|
||||||
|
let err = invoker
|
||||||
|
.call(
|
||||||
|
tool,
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"templateId": template_id,
|
||||||
|
"name": "Base",
|
||||||
|
"content": "body",
|
||||||
|
"defaultProfileId": Uuid::from_u128(9),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("non-allowlisted template write must be rejected");
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)),
|
||||||
|
"expected readable rejection for {tool}, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls = template_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].1, "idea_template_update");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn openai_ticket_assistant_policy_still_bounds_ticket() {
|
async fn openai_ticket_assistant_policy_still_bounds_ticket() {
|
||||||
let temp = std::env::temp_dir().join(format!(
|
let temp = std::env::temp_dir().join(format!(
|
||||||
@ -603,12 +738,14 @@ mod tests {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
policies,
|
policies,
|
||||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools,
|
||||||
);
|
);
|
||||||
|
|
||||||
let denied = invoker
|
let denied = invoker
|
||||||
|
|||||||
@ -10,12 +10,13 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use backend::BackendCore;
|
use backend::BackendCore;
|
||||||
use infrastructure::TicketToolProvider;
|
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
use crate::embedded_server::EmbeddedServerController;
|
use crate::embedded_server::EmbeddedServerController;
|
||||||
use crate::pty::PtyBridge;
|
use crate::pty::PtyBridge;
|
||||||
|
use crate::templates::AppTemplateToolProvider;
|
||||||
use crate::tickets::AppTicketToolProvider;
|
use crate::tickets::AppTicketToolProvider;
|
||||||
|
|
||||||
pub use backend::ResumeContext;
|
pub use backend::ResumeContext;
|
||||||
@ -71,6 +72,14 @@ impl AppState {
|
|||||||
unlink: Arc::clone(&core.unlink_issues),
|
unlink: Arc::clone(&core.unlink_issues),
|
||||||
list_sprints: Arc::clone(&core.list_sprints),
|
list_sprints: Arc::clone(&core.list_sprints),
|
||||||
}) as Arc<dyn TicketToolProvider>);
|
}) as Arc<dyn TicketToolProvider>);
|
||||||
|
core.template_tool_binder
|
||||||
|
.bind(Arc::new(AppTemplateToolProvider {
|
||||||
|
create: Arc::clone(&core.create_template),
|
||||||
|
read: Arc::clone(&core.read_template),
|
||||||
|
list: Arc::clone(&core.list_templates),
|
||||||
|
update: Arc::clone(&core.update_template),
|
||||||
|
delete: Arc::clone(&core.delete_template),
|
||||||
|
}) as Arc<dyn TemplateToolProvider>);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
core,
|
core,
|
||||||
|
|||||||
357
crates/app-tauri/src/templates.rs
Normal file
357
crates/app-tauri/src/templates.rs
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
//! Desktop-owned MCP template tool provider.
|
||||||
|
//!
|
||||||
|
//! This module is a driving-adapter bridge: it maps the public MCP
|
||||||
|
//! `idea_template_*` tools to the existing application template use cases.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
CreateTemplate, CreateTemplateInput, DeleteTemplate, DeleteTemplateInput, ListTemplates,
|
||||||
|
ReadTemplate, ReadTemplateInput, UpdateTemplate, UpdateTemplateInput,
|
||||||
|
};
|
||||||
|
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};
|
||||||
|
|
||||||
|
/// Concrete provider for the public MCP template tools.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppTemplateToolProvider {
|
||||||
|
/// Template creation use case.
|
||||||
|
pub create: Arc<CreateTemplate>,
|
||||||
|
/// Template read use case.
|
||||||
|
pub read: Arc<ReadTemplate>,
|
||||||
|
/// Template list use case.
|
||||||
|
pub list: Arc<ListTemplates>,
|
||||||
|
/// Template update use case.
|
||||||
|
pub update: Arc<UpdateTemplate>,
|
||||||
|
/// Template delete use case.
|
||||||
|
pub delete: Arc<DeleteTemplate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateToolProvider for AppTemplateToolProvider {
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, TemplateToolError> {
|
||||||
|
let result = match name {
|
||||||
|
"idea_template_list" => {
|
||||||
|
let templates = self.list.execute().await.map_err(template_error)?;
|
||||||
|
json!(TemplateListDto::from(templates))
|
||||||
|
}
|
||||||
|
"idea_template_read" => {
|
||||||
|
let template_id = parse_json_template_id(&arguments)?;
|
||||||
|
let template = self
|
||||||
|
.read
|
||||||
|
.execute(ReadTemplateInput { template_id })
|
||||||
|
.await
|
||||||
|
.map_err(template_error)?;
|
||||||
|
json!(TemplateDto::from(template))
|
||||||
|
}
|
||||||
|
"idea_template_create" => {
|
||||||
|
let template = self
|
||||||
|
.create
|
||||||
|
.execute(CreateTemplateInput {
|
||||||
|
name: required_str(&arguments, "name")?.to_owned(),
|
||||||
|
content: required_str(&arguments, "content")?.to_owned(),
|
||||||
|
default_profile_id: parse_profile_id(required_str(
|
||||||
|
&arguments,
|
||||||
|
"defaultProfileId",
|
||||||
|
)?)
|
||||||
|
.map_err(dto_tool_error)?,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(template_error)?;
|
||||||
|
json!(TemplateDto::from(template))
|
||||||
|
}
|
||||||
|
"idea_template_update" => {
|
||||||
|
let template = self
|
||||||
|
.update
|
||||||
|
.execute(UpdateTemplateInput {
|
||||||
|
template_id: parse_json_template_id(&arguments)?,
|
||||||
|
content: required_str(&arguments, "content")?.to_owned(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(template_error)?;
|
||||||
|
json!(TemplateDto::from(template))
|
||||||
|
}
|
||||||
|
"idea_template_delete" => {
|
||||||
|
let template_id = parse_json_template_id(&arguments)?;
|
||||||
|
self.delete
|
||||||
|
.execute(DeleteTemplateInput { template_id })
|
||||||
|
.await
|
||||||
|
.map_err(template_error)?;
|
||||||
|
json!({ "deleted": true, "templateId": template_id })
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(TemplateToolError::new(
|
||||||
|
"notFound",
|
||||||
|
format!("unknown template tool: {other}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn template_error(err: application::AppError) -> TemplateToolError {
|
||||||
|
let code = match err.code() {
|
||||||
|
"NOT_FOUND" => "notFound",
|
||||||
|
"INVALID" => "invalid",
|
||||||
|
"STORE" => "store",
|
||||||
|
"FILESYSTEM" => "filesystem",
|
||||||
|
_ => "internal",
|
||||||
|
};
|
||||||
|
TemplateToolError::new(code, err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dto_tool_error(err: crate::dto::ErrorDto) -> TemplateToolError {
|
||||||
|
let code = match err.code.as_str() {
|
||||||
|
"NOT_FOUND" => "notFound",
|
||||||
|
"INVALID" => "invalid",
|
||||||
|
"STORE" => "store",
|
||||||
|
"FILESYSTEM" => "filesystem",
|
||||||
|
_ => "internal",
|
||||||
|
};
|
||||||
|
TemplateToolError::new(code, err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_template_id(arguments: &Value) -> Result<TemplateId, TemplateToolError> {
|
||||||
|
parse_template_id(required_str(arguments, "templateId")?).map_err(dto_tool_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TemplateToolError> {
|
||||||
|
arguments
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| TemplateToolError::new("invalid", format!("missing {key}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
CreateTemplate, DeleteTemplate, ListTemplates, ReadTemplate, UpdateTemplate,
|
||||||
|
};
|
||||||
|
use domain::ports::{EventBus, EventStream, IdGenerator, StoreError, TemplateStore};
|
||||||
|
use domain::{
|
||||||
|
AgentTemplate, DomainEvent, MarkdownDoc, ProfileId, ProjectId, ProjectPath, RemoteRef,
|
||||||
|
TemplateVersion,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeTemplateStore {
|
||||||
|
templates: Mutex<HashMap<TemplateId, AgentTemplate>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateStore for FakeTemplateStore {
|
||||||
|
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
|
||||||
|
Ok(self.templates.lock().unwrap().values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
|
||||||
|
self.templates
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
|
||||||
|
self.templates
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(template.id, template.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
|
||||||
|
self.templates
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&id)
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(StoreError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeIds;
|
||||||
|
|
||||||
|
impl IdGenerator for FakeIds {
|
||||||
|
fn new_uuid(&self) -> Uuid {
|
||||||
|
Uuid::from_u128(81)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeEvents {
|
||||||
|
events: Mutex<Vec<DomainEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus for FakeEvents {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.events.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||||
|
"demo",
|
||||||
|
ProjectPath::new("/tmp/project").unwrap(),
|
||||||
|
RemoteRef::local(),
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider(store: Arc<FakeTemplateStore>, events: Arc<FakeEvents>) -> AppTemplateToolProvider {
|
||||||
|
let store_port = store as Arc<dyn TemplateStore>;
|
||||||
|
AppTemplateToolProvider {
|
||||||
|
create: Arc::new(CreateTemplate::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::new(FakeIds),
|
||||||
|
)),
|
||||||
|
read: Arc::new(ReadTemplate::new(Arc::clone(&store_port))),
|
||||||
|
list: Arc::new(ListTemplates::new(Arc::clone(&store_port))),
|
||||||
|
update: Arc::new(UpdateTemplate::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
events as Arc<dyn EventBus>,
|
||||||
|
)),
|
||||||
|
delete: Arc::new(DeleteTemplate::new(store_port)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn template_provider_crud_round_trips_and_update_publishes_event() {
|
||||||
|
let store = Arc::new(FakeTemplateStore::default());
|
||||||
|
let events = Arc::new(FakeEvents::default());
|
||||||
|
let provider = provider(Arc::clone(&store), Arc::clone(&events));
|
||||||
|
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
|
||||||
|
|
||||||
|
let created = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_create",
|
||||||
|
json!({
|
||||||
|
"name": "Base",
|
||||||
|
"content": "v1",
|
||||||
|
"defaultProfileId": profile_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let template_id = created["id"].as_str().unwrap().to_owned();
|
||||||
|
assert_eq!(created["version"], json!(TemplateVersion::INITIAL));
|
||||||
|
|
||||||
|
let read = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_read",
|
||||||
|
json!({ "templateId": template_id }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(read["name"], "Base");
|
||||||
|
assert_eq!(read["contentMd"], "v1");
|
||||||
|
|
||||||
|
let listed = provider
|
||||||
|
.handle_template_tool(&project(), "agent", "idea_template_list", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let updated = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_update",
|
||||||
|
json!({ "templateId": template_id, "content": "v2" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(updated["contentMd"], "v2");
|
||||||
|
assert_eq!(updated["version"], json!(2));
|
||||||
|
assert!(matches!(
|
||||||
|
events.events.lock().unwrap().as_slice(),
|
||||||
|
[DomainEvent::TemplateUpdated { .. }]
|
||||||
|
));
|
||||||
|
|
||||||
|
let deleted = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_delete",
|
||||||
|
json!({ "templateId": template_id }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deleted["deleted"], true);
|
||||||
|
|
||||||
|
let missing = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_read",
|
||||||
|
json!({ "templateId": template_id }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("deleted template must be missing");
|
||||||
|
assert_eq!(missing.code, "notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn template_provider_rejects_invalid_tool_arguments() {
|
||||||
|
let provider = provider(
|
||||||
|
Arc::new(FakeTemplateStore::default()),
|
||||||
|
Arc::new(FakeEvents::default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = provider
|
||||||
|
.handle_template_tool(
|
||||||
|
&project(),
|
||||||
|
"agent",
|
||||||
|
"idea_template_create",
|
||||||
|
json!({ "name": "Base", "content": "v1", "defaultProfileId": "bad" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("bad profile id should be invalid");
|
||||||
|
|
||||||
|
assert_eq!(err.code, "invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_provider_preserves_content_only_b4_boundary() {
|
||||||
|
let template = AgentTemplate::new(
|
||||||
|
TemplateId::from_uuid(Uuid::from_u128(7)),
|
||||||
|
"Base",
|
||||||
|
MarkdownDoc::new("v1"),
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(9)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let updated = template.with_updated_content(MarkdownDoc::new("v2"));
|
||||||
|
|
||||||
|
assert_eq!(updated.name, "Base");
|
||||||
|
assert_eq!(updated.default_profile_id, template.default_profile_id);
|
||||||
|
assert_eq!(updated.version, TemplateVersion(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -164,9 +164,9 @@ pub use template::{
|
|||||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||||
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
||||||
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
|
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput,
|
||||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
|
||||||
UpdateTemplateOutput,
|
SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
pub use terminal::{
|
pub use terminal::{
|
||||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,
|
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ pub use usecases::{
|
|||||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||||
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
|
||||||
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate,
|
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput,
|
||||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
|
||||||
UpdateTemplateOutput,
|
SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -134,9 +134,47 @@ impl UpdateTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ListTemplates / DeleteTemplate
|
// ReadTemplate / ListTemplates / DeleteTemplate
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`ReadTemplate::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadTemplateInput {
|
||||||
|
/// Template to read.
|
||||||
|
pub template_id: TemplateId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`ReadTemplate::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadTemplateOutput {
|
||||||
|
/// The requested template.
|
||||||
|
pub template: AgentTemplate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one template from the global store.
|
||||||
|
pub struct ReadTemplate {
|
||||||
|
templates: Arc<dyn TemplateStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadTemplate {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(templates: Arc<dyn TemplateStore>) -> Self {
|
||||||
|
Self { templates }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one template.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if the template is unknown,
|
||||||
|
/// - [`AppError::Store`] on persistence failure.
|
||||||
|
pub async fn execute(&self, input: ReadTemplateInput) -> Result<ReadTemplateOutput, AppError> {
|
||||||
|
Ok(ReadTemplateOutput {
|
||||||
|
template: self.templates.get(input.template_id).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Output of [`ListTemplates::execute`].
|
/// Output of [`ListTemplates::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ListTemplatesOutput {
|
pub struct ListTemplatesOutput {
|
||||||
|
|||||||
@ -18,8 +18,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput,
|
CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput,
|
||||||
DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
|
DetectAgentDrift, DetectAgentDriftInput, ReadTemplate, ReadTemplateInput,
|
||||||
UpdateTemplate, UpdateTemplateInput,
|
SyncAgentWithTemplate, SyncAgentWithTemplateInput, UpdateTemplate, UpdateTemplateInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -224,6 +224,29 @@ async fn create_template_starts_at_initial_version() {
|
|||||||
assert_eq!(store.list().await.unwrap().len(), 1);
|
assert_eq!(store.list().await.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_template_returns_one_template_or_not_found() {
|
||||||
|
let existing = template(tid(7), "Base", "body", 3);
|
||||||
|
let store = FakeTemplates::with(vec![existing.clone()]);
|
||||||
|
let read = ReadTemplate::new(Arc::new(store));
|
||||||
|
|
||||||
|
let out = read
|
||||||
|
.execute(ReadTemplateInput {
|
||||||
|
template_id: existing.id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out.template, existing);
|
||||||
|
|
||||||
|
let err = read
|
||||||
|
.execute(ReadTemplateInput {
|
||||||
|
template_id: tid(8),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("unknown template should be not found");
|
||||||
|
assert_eq!(err.code(), "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_template_bumps_version_and_publishes_event() {
|
async fn update_template_bumps_version_and_publishes_event() {
|
||||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);
|
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);
|
||||||
|
|||||||
@ -2444,8 +2444,8 @@ impl From<ListResumableAgentsOutput> for ResumableAgentListDto {
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
|
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
|
||||||
DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput,
|
DetectAgentDriftOutput, ListTemplatesOutput, ReadTemplateOutput, SyncAgentWithTemplateOutput,
|
||||||
UpdateTemplateOutput,
|
UpdateTemplateInput, UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
use domain::{AgentTemplate, TemplateId};
|
use domain::{AgentTemplate, TemplateId};
|
||||||
|
|
||||||
@ -2473,6 +2473,12 @@ impl From<CreateTemplateOutput> for TemplateDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ReadTemplateOutput> for TemplateDto {
|
||||||
|
fn from(out: ReadTemplateOutput) -> Self {
|
||||||
|
Self(out.template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<UpdateTemplateOutput> for TemplateDto {
|
impl From<UpdateTemplateOutput> for TemplateDto {
|
||||||
fn from(out: UpdateTemplateOutput) -> Self {
|
fn from(out: UpdateTemplateOutput) -> Self {
|
||||||
Self(out.template)
|
Self(out.template)
|
||||||
|
|||||||
@ -32,7 +32,7 @@ use application::{
|
|||||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||||
@ -83,9 +83,10 @@ use infrastructure::{
|
|||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
||||||
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||||
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock,
|
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock,
|
||||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||||
|
VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -174,6 +175,48 @@ impl TicketToolProvider for LateBoundTicketToolProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct LateBoundTemplateToolProvider {
|
||||||
|
inner: Arc<Mutex<Option<Arc<dyn TemplateToolProvider>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LateBoundTemplateToolProvider {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind(&self, inner: Arc<dyn TemplateToolProvider>) {
|
||||||
|
*self.inner.lock().expect("template tool mutex poisoned") = Some(inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateToolProvider for LateBoundTemplateToolProvider {
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, infrastructure::TemplateToolError> {
|
||||||
|
let inner = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("template tool mutex poisoned")
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
infrastructure::TemplateToolError::new(
|
||||||
|
"UNAVAILABLE",
|
||||||
|
"template tool provider not bound".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
inner
|
||||||
|
.handle_template_tool(project, requester, name, arguments)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`]
|
/// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`]
|
||||||
/// ciblant le **project root** du tour en cours.
|
/// ciblant le **project root** du tour en cours.
|
||||||
///
|
///
|
||||||
@ -937,6 +980,8 @@ pub struct BackendCore {
|
|||||||
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
||||||
/// MCP provider for the public `idea_ticket_*` tools.
|
/// MCP provider for the public `idea_ticket_*` tools.
|
||||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||||
|
/// MCP provider for the public `idea_template_*` tools.
|
||||||
|
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
|
||||||
/// Open an ephemeral AI assistant chat bound to one ticket.
|
/// Open an ephemeral AI assistant chat bound to one ticket.
|
||||||
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
||||||
/// Close an ephemeral AI assistant chat bound to one ticket.
|
/// Close an ephemeral AI assistant chat bound to one ticket.
|
||||||
@ -1017,6 +1062,8 @@ pub struct BackendCore {
|
|||||||
// --- Templates & sync (L7) ---
|
// --- Templates & sync (L7) ---
|
||||||
/// Create a template in the global store.
|
/// Create a template in the global store.
|
||||||
pub create_template: Arc<CreateTemplate>,
|
pub create_template: Arc<CreateTemplate>,
|
||||||
|
/// Read a template in the global store.
|
||||||
|
pub read_template: Arc<ReadTemplate>,
|
||||||
/// Update a template's content (bumps version).
|
/// Update a template's content (bumps version).
|
||||||
pub update_template: Arc<UpdateTemplate>,
|
pub update_template: Arc<UpdateTemplate>,
|
||||||
/// List all templates in the global store.
|
/// List all templates in the global store.
|
||||||
@ -1138,6 +1185,8 @@ pub struct BackendCore {
|
|||||||
pub home_dir: String,
|
pub home_dir: String,
|
||||||
/// Late-bound concrete ticket MCP provider supplied by the driving adapter.
|
/// Late-bound concrete ticket MCP provider supplied by the driving adapter.
|
||||||
pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>,
|
pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>,
|
||||||
|
/// Late-bound concrete template MCP provider supplied by the driving adapter.
|
||||||
|
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendCore {
|
impl BackendCore {
|
||||||
@ -1474,6 +1523,8 @@ impl BackendCore {
|
|||||||
));
|
));
|
||||||
let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new());
|
let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new());
|
||||||
let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
|
let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
|
||||||
|
let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new());
|
||||||
|
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
|
||||||
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
||||||
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
||||||
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
|
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
|
||||||
@ -1841,6 +1892,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&template_store_port),
|
Arc::clone(&template_store_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
));
|
||||||
|
let read_template = Arc::new(ReadTemplate::new(Arc::clone(&template_store_port)));
|
||||||
let update_template = Arc::new(UpdateTemplate::new(
|
let update_template = Arc::new(UpdateTemplate::new(
|
||||||
Arc::clone(&template_store_port),
|
Arc::clone(&template_store_port),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
@ -2384,6 +2436,7 @@ impl BackendCore {
|
|||||||
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
||||||
Arc::clone(&mcp_tool_permission_store_port),
|
Arc::clone(&mcp_tool_permission_store_port),
|
||||||
Arc::clone(&ticket_tool_provider),
|
Arc::clone(&ticket_tool_provider),
|
||||||
|
Arc::clone(&template_tool_provider),
|
||||||
)) as Arc<dyn ToolInvoker>);
|
)) as Arc<dyn ToolInvoker>);
|
||||||
|
|
||||||
let stop_live_agent = Arc::new(
|
let stop_live_agent = Arc::new(
|
||||||
@ -2472,6 +2525,7 @@ impl BackendCore {
|
|||||||
assign_ticket_to_sprint,
|
assign_ticket_to_sprint,
|
||||||
unassign_ticket_from_sprint,
|
unassign_ticket_from_sprint,
|
||||||
ticket_tool_provider,
|
ticket_tool_provider,
|
||||||
|
template_tool_provider,
|
||||||
open_ticket_assistant,
|
open_ticket_assistant,
|
||||||
close_ticket_assistant,
|
close_ticket_assistant,
|
||||||
tool_policy_registry,
|
tool_policy_registry,
|
||||||
@ -2500,6 +2554,7 @@ impl BackendCore {
|
|||||||
update_agent_permissions,
|
update_agent_permissions,
|
||||||
resolve_agent_permissions,
|
resolve_agent_permissions,
|
||||||
create_template,
|
create_template,
|
||||||
|
read_template,
|
||||||
update_template,
|
update_template,
|
||||||
list_templates,
|
list_templates,
|
||||||
delete_template,
|
delete_template,
|
||||||
@ -2552,6 +2607,7 @@ impl BackendCore {
|
|||||||
retry_background_task,
|
retry_background_task,
|
||||||
background_task_store: Arc::clone(&background_tasks_port),
|
background_task_store: Arc::clone(&background_tasks_port),
|
||||||
ticket_tool_binder,
|
ticket_tool_binder,
|
||||||
|
template_tool_binder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2640,6 +2696,7 @@ impl BackendCore {
|
|||||||
.with_events(events)
|
.with_events(events)
|
||||||
.with_ready_sink(ready_sink)
|
.with_ready_sink(ready_sink)
|
||||||
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
|
||||||
|
.with_template_tools(Arc::clone(&self.template_tool_provider))
|
||||||
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
|
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
|
||||||
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
||||||
endpoint,
|
endpoint,
|
||||||
@ -4569,6 +4626,12 @@ mod mcp_serve_peer_tests {
|
|||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
"idea_sprint_list",
|
"idea_sprint_list",
|
||||||
|
// Public template tools.
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
|
"idea_template_create",
|
||||||
|
"idea_template_update",
|
||||||
|
"idea_template_delete",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -4578,8 +4641,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
25,
|
30,
|
||||||
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
"exactly the thirty exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
@ -14,7 +14,7 @@ use domain::ports::{
|
|||||||
ToolSpec,
|
ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
|
||||||
use infrastructure::TicketToolProvider;
|
use infrastructure::{TemplateToolProvider, TicketToolProvider};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker {
|
|||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||||
@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker {
|
|||||||
policies: Arc<dyn AgentToolPolicyStore>,
|
policies: Arc<dyn AgentToolPolicyStore>,
|
||||||
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
|
||||||
ticket_tools: Arc<dyn TicketToolProvider>,
|
ticket_tools: Arc<dyn TicketToolProvider>,
|
||||||
|
template_tools: Arc<dyn TemplateToolProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
orchestrator,
|
orchestrator,
|
||||||
@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker {
|
|||||||
policies,
|
policies,
|
||||||
mcp_tool_permissions,
|
mcp_tool_permissions,
|
||||||
ticket_tools,
|
ticket_tools,
|
||||||
|
template_tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
|
|||||||
return serde_json::to_string(&value)
|
return serde_json::to_string(&value)
|
||||||
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}")));
|
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}")));
|
||||||
}
|
}
|
||||||
|
if infrastructure::orchestrator::mcp::tools::is_template_tool(name) {
|
||||||
|
let value = self
|
||||||
|
.template_tools
|
||||||
|
.handle_template_tool(&project, &requester, name, value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let detail =
|
||||||
|
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
|
||||||
|
ToolInvocationError::Execution(detail)
|
||||||
|
})?;
|
||||||
|
return serde_json::to_string(&value)
|
||||||
|
.map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}")));
|
||||||
|
}
|
||||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||||
@ -324,7 +340,7 @@ mod tests {
|
|||||||
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
|
||||||
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
|
||||||
};
|
};
|
||||||
use infrastructure::TicketToolError;
|
use infrastructure::{TemplateToolError, TicketToolError};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -448,6 +464,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeTemplateTools {
|
||||||
|
calls: Mutex<Vec<(String, String, Value)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateToolProvider for FakeTemplateTools {
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, TemplateToolError> {
|
||||||
|
self.calls.lock().unwrap().push((
|
||||||
|
requester.to_owned(),
|
||||||
|
name.to_owned(),
|
||||||
|
arguments.clone(),
|
||||||
|
));
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"requester": requester,
|
||||||
|
"templateId": arguments.get("templateId").and_then(Value::as_str).unwrap_or_default(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn issue_ref(raw: &str) -> IssueRef {
|
fn issue_ref(raw: &str) -> IssueRef {
|
||||||
IssueRef::from_str(raw).unwrap()
|
IssueRef::from_str(raw).unwrap()
|
||||||
}
|
}
|
||||||
@ -572,12 +615,14 @@ mod tests {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
policies,
|
policies,
|
||||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools,
|
||||||
);
|
);
|
||||||
|
|
||||||
let denied = invoker
|
let denied = invoker
|
||||||
@ -635,17 +680,20 @@ mod tests {
|
|||||||
let core = crate::BackendCore::build(temp.clone());
|
let core = crate::BackendCore::build(temp.clone());
|
||||||
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
Arc::new(FakePolicies::default()),
|
Arc::new(FakePolicies::default()),
|
||||||
mcp_permissions(ProjectMcpToolPermissions::default()),
|
mcp_permissions(ProjectMcpToolPermissions::default()),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for tool in ["idea_ticket_list", "idea_template_list"] {
|
||||||
invoker
|
invoker
|
||||||
.call(
|
.call(
|
||||||
"idea_ticket_list",
|
tool,
|
||||||
&json!({
|
&json!({
|
||||||
PROJECT_ROOT_ARG: "/tmp/project",
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
REQUESTER_ARG: requester,
|
REQUESTER_ARG: requester,
|
||||||
@ -654,6 +702,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("read tool must pass the durable read-only policy");
|
.expect("read tool must pass the durable read-only policy");
|
||||||
|
}
|
||||||
|
|
||||||
for (tool, arguments) in [
|
for (tool, arguments) in [
|
||||||
(
|
(
|
||||||
@ -672,6 +721,18 @@ mod tests {
|
|||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
json!({ "label": "task", "command": "echo" }),
|
json!({ "label": "task", "command": "echo" }),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"idea_template_create",
|
||||||
|
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_template_update",
|
||||||
|
json!({ "templateId": Uuid::new_v4(), "content": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"idea_template_delete",
|
||||||
|
json!({ "templateId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let mut payload = arguments.as_object().unwrap().clone();
|
let mut payload = arguments.as_object().unwrap().clone();
|
||||||
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
|
||||||
@ -689,6 +750,13 @@ mod tests {
|
|||||||
let calls = ticket_tools.calls.lock().unwrap();
|
let calls = ticket_tools.calls.lock().unwrap();
|
||||||
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
assert_eq!(calls.len(), 1, "only the read ticket tool should run");
|
||||||
assert_eq!(calls[0].1, "idea_ticket_list");
|
assert_eq!(calls[0].1, "idea_ticket_list");
|
||||||
|
let template_calls = template_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
template_calls.len(),
|
||||||
|
1,
|
||||||
|
"only the read template tool should run"
|
||||||
|
);
|
||||||
|
assert_eq!(template_calls[0].1, "idea_template_list");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
@ -701,12 +769,14 @@ mod tests {
|
|||||||
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
let requester = agent.to_string();
|
let requester = agent.to_string();
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let invoker = AppOpenAiToolInvoker::new(
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&core.orchestrator_service),
|
Arc::clone(&core.orchestrator_service),
|
||||||
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
Arc::new(FakePolicies::default()),
|
Arc::new(FakePolicies::default()),
|
||||||
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
|
||||||
ticket_tools.clone(),
|
ticket_tools.clone(),
|
||||||
|
template_tools,
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = invoker
|
let result = invoker
|
||||||
@ -733,4 +803,71 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(temp);
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_durable_agent_override_allows_only_explicit_template_write_tool() {
|
||||||
|
let temp = std::env::temp_dir().join(format!(
|
||||||
|
"idea-openai-template-permissions-{}",
|
||||||
|
Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let core = crate::BackendCore::build(temp.clone());
|
||||||
|
let agent = AgentId::from_uuid(Uuid::from_u128(84));
|
||||||
|
let requester = agent.to_string();
|
||||||
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
|
let template_id = Uuid::from_u128(7).to_string();
|
||||||
|
let invoker = AppOpenAiToolInvoker::new(
|
||||||
|
Arc::clone(&core.orchestrator_service),
|
||||||
|
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
|
||||||
|
Arc::new(FakePolicies::default()),
|
||||||
|
mcp_permissions(allow_doc(agent, &["idea_template_update"])),
|
||||||
|
ticket_tools,
|
||||||
|
template_tools.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = invoker
|
||||||
|
.call(
|
||||||
|
"idea_template_update",
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"templateId": template_id,
|
||||||
|
"content": "body",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("explicit durable override should reach template provider");
|
||||||
|
let result: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(result["requester"], requester);
|
||||||
|
assert_eq!(result["templateId"], template_id);
|
||||||
|
|
||||||
|
for tool in ["idea_template_create", "idea_template_delete"] {
|
||||||
|
let err = invoker
|
||||||
|
.call(
|
||||||
|
tool,
|
||||||
|
&json!({
|
||||||
|
PROJECT_ROOT_ARG: "/tmp/project",
|
||||||
|
REQUESTER_ARG: requester,
|
||||||
|
"templateId": template_id,
|
||||||
|
"name": "Base",
|
||||||
|
"content": "body",
|
||||||
|
"defaultProfileId": Uuid::from_u128(9),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("non-allowlisted template write must be rejected");
|
||||||
|
assert!(
|
||||||
|
matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)),
|
||||||
|
"expected readable rejection for {tool}, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls = template_tools.calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].1, "idea_template_update");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(temp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,8 +71,8 @@ pub use model_server::{
|
|||||||
LocalManagedProcess,
|
LocalManagedProcess,
|
||||||
};
|
};
|
||||||
pub use orchestrator::mcp::{
|
pub use orchestrator::mcp::{
|
||||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
McpServer, MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider,
|
||||||
ToolPolicyRegistry,
|
TicketToolError, TicketToolProvider, ToolPolicyRegistry,
|
||||||
};
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
pub mod jsonrpc;
|
pub mod jsonrpc;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod templates;
|
||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
@ -40,6 +41,7 @@ pub use jsonrpc::{
|
|||||||
};
|
};
|
||||||
pub use policy::ToolPolicyRegistry;
|
pub use policy::ToolPolicyRegistry;
|
||||||
pub use server::McpServer;
|
pub use server::McpServer;
|
||||||
|
pub use templates::{TemplateToolError, TemplateToolProvider};
|
||||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||||
pub use transport::{MemoryTransport, StdioTransport};
|
pub use transport::{MemoryTransport, StdioTransport};
|
||||||
|
|||||||
@ -33,6 +33,7 @@ use super::jsonrpc::{
|
|||||||
JSONRPC_VERSION,
|
JSONRPC_VERSION,
|
||||||
};
|
};
|
||||||
use super::policy::ToolPolicyRegistry;
|
use super::policy::ToolPolicyRegistry;
|
||||||
|
use super::templates::TemplateToolProvider;
|
||||||
use super::tickets::TicketToolProvider;
|
use super::tickets::TicketToolProvider;
|
||||||
use super::tools::{self, ToolMapError};
|
use super::tools::{self, ToolMapError};
|
||||||
|
|
||||||
@ -70,6 +71,9 @@ pub struct McpServer {
|
|||||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||||
/// provider maps those calls to application/domain `Issue` use cases.
|
/// provider maps those calls to application/domain `Issue` use cases.
|
||||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||||
|
/// Optional public template provider. The MCP surface says `template`; the
|
||||||
|
/// provider maps those calls to application/domain `Template` use cases.
|
||||||
|
template_tools: Option<Arc<dyn TemplateToolProvider>>,
|
||||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||||
@ -90,6 +94,7 @@ impl McpServer {
|
|||||||
requester: String::new(),
|
requester: String::new(),
|
||||||
ready_sink: None,
|
ready_sink: None,
|
||||||
ticket_tools: None,
|
ticket_tools: None,
|
||||||
|
template_tools: None,
|
||||||
tool_policies: None,
|
tool_policies: None,
|
||||||
mcp_tool_permissions: None,
|
mcp_tool_permissions: None,
|
||||||
}
|
}
|
||||||
@ -123,6 +128,13 @@ impl McpServer {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches the public template tool provider.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_template_tools(mut self, template_tools: Arc<dyn TemplateToolProvider>) -> Self {
|
||||||
|
self.template_tools = Some(template_tools);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Attaches the requester-scoped MCP tool policy registry.
|
/// Attaches the requester-scoped MCP tool policy registry.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||||
@ -157,6 +169,7 @@ impl McpServer {
|
|||||||
requester: requester.into(),
|
requester: requester.into(),
|
||||||
ready_sink: self.ready_sink.clone(),
|
ready_sink: self.ready_sink.clone(),
|
||||||
ticket_tools: self.ticket_tools.clone(),
|
ticket_tools: self.ticket_tools.clone(),
|
||||||
|
template_tools: self.template_tools.clone(),
|
||||||
tool_policies: self.tool_policies.clone(),
|
tool_policies: self.tool_policies.clone(),
|
||||||
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||||
}
|
}
|
||||||
@ -507,6 +520,44 @@ impl McpServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tools::is_template_tool(&name) {
|
||||||
|
let result = match &self.template_tools {
|
||||||
|
Some(provider) => {
|
||||||
|
provider
|
||||||
|
.handle_template_tool(&self.project, &self.requester, &name, arguments)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => Err(super::templates::TemplateToolError::new(
|
||||||
|
"notConfigured",
|
||||||
|
"template tools are not configured",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
self.publish_processed(&name, result.is_ok());
|
||||||
|
return match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||||
|
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 text =
|
||||||
|
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| 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={}",
|
||||||
|
text.len(),
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
);
|
||||||
|
Ok(tool_result_text(&text, true))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The handshake-provided requester is still passed to the mapper for tools that
|
// The handshake-provided requester is still passed to the mapper for tools that
|
||||||
// need peer identity.
|
// need peer identity.
|
||||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||||
|
|||||||
133
crates/infrastructure/src/orchestrator/mcp/templates.rs
Normal file
133
crates/infrastructure/src/orchestrator/mcp/templates.rs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//! Public MCP template tools.
|
||||||
|
//!
|
||||||
|
//! The MCP surface exposes global agent templates without adding them to the
|
||||||
|
//! orchestration command enum. A driving adapter injects the provider that maps
|
||||||
|
//! these calls to the application template use cases.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::Project;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::tools::ToolDef;
|
||||||
|
|
||||||
|
/// Error returned by an MCP template tool provider.
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
#[error("{code}: {message}")]
|
||||||
|
pub struct TemplateToolError {
|
||||||
|
/// Stable machine-readable code.
|
||||||
|
pub code: &'static str,
|
||||||
|
/// Human-readable message.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateToolError {
|
||||||
|
/// Builds a typed template-tool error.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialises the error as a camelCase JSON object.
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_value(&self) -> Value {
|
||||||
|
json!({ "code": self.code, "message": self.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider injected by the composition root to execute public template tools.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TemplateToolProvider: Send + Sync {
|
||||||
|
/// Executes one public template tool.
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
requester: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, TemplateToolError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when `name` is one of the public template tools.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_template_tool(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"idea_template_list"
|
||||||
|
| "idea_template_read"
|
||||||
|
| "idea_template_create"
|
||||||
|
| "idea_template_update"
|
||||||
|
| "idea_template_delete"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public template tool definitions advertised by `tools/list`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn catalogue() -> Vec<ToolDef> {
|
||||||
|
let template_id = json!({ "type": "string", "format": "uuid" });
|
||||||
|
|
||||||
|
vec![
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_template_list",
|
||||||
|
description: "List global IdeA agent templates.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_template_read",
|
||||||
|
description: "Read one global IdeA agent template by id.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"templateId": template_id.clone()
|
||||||
|
},
|
||||||
|
"required": ["templateId"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_template_create",
|
||||||
|
description: "Create a global IdeA agent template.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"content": { "type": "string" },
|
||||||
|
"defaultProfileId": { "type": "string", "format": "uuid" }
|
||||||
|
},
|
||||||
|
"required": ["name", "content", "defaultProfileId"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_template_update",
|
||||||
|
description: "Update a global IdeA agent template's Markdown content.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"templateId": template_id.clone(),
|
||||||
|
"content": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["templateId", "content"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_template_delete",
|
||||||
|
description: "Delete a global IdeA agent template.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"templateId": template_id
|
||||||
|
},
|
||||||
|
"required": ["templateId"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -54,6 +54,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
|||||||
"idea_ticket_list",
|
"idea_ticket_list",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
"idea_sprint_list",
|
"idea_sprint_list",
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Canonical write/action MCP tools denied by default.
|
/// Canonical write/action MCP tools denied by default.
|
||||||
@ -74,6 +76,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
|
"idea_template_create",
|
||||||
|
"idea_template_update",
|
||||||
|
"idea_template_delete",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// All MCP tool names that have an explicit access classification.
|
/// All MCP tool names that have an explicit access classification.
|
||||||
@ -127,6 +132,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
| "idea_run_in_background"
|
| "idea_run_in_background"
|
||||||
| "idea_workstate_read"
|
| "idea_workstate_read"
|
||||||
) || is_ticket_tool(tool)
|
) || is_ticket_tool(tool)
|
||||||
|
|| is_template_tool(tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `tool` is a public ticket MCP tool.
|
/// Whether `tool` is a public ticket MCP tool.
|
||||||
@ -135,6 +141,12 @@ pub fn is_ticket_tool(tool: &str) -> bool {
|
|||||||
super::tickets::is_ticket_tool(tool)
|
super::tickets::is_ticket_tool(tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `tool` is a public template MCP tool.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_template_tool(tool: &str) -> bool {
|
||||||
|
super::templates::is_template_tool(tool)
|
||||||
|
}
|
||||||
|
|
||||||
/// The full catalogue advertised on `tools/list`.
|
/// The full catalogue advertised on `tools/list`.
|
||||||
///
|
///
|
||||||
/// Exactly the tools whose mapping target already exists as an
|
/// Exactly the tools whose mapping target already exists as an
|
||||||
@ -349,6 +361,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
tools.extend(super::tickets::catalogue());
|
tools.extend(super::tickets::catalogue());
|
||||||
|
tools.extend(super::templates::catalogue());
|
||||||
tools
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,7 +57,9 @@ use application::{
|
|||||||
};
|
};
|
||||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
use infrastructure::orchestrator::mcp::{
|
||||||
|
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||||
|
};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||||
SystemMillisClock, ToolPolicyRegistry,
|
SystemMillisClock, ToolPolicyRegistry,
|
||||||
@ -560,6 +562,52 @@ impl FakeTicketTools {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeTemplateTools {
|
||||||
|
calls: Arc<Mutex<Vec<String>>>,
|
||||||
|
mutation_attempts: Arc<Mutex<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeTemplateTools {
|
||||||
|
fn calls(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mutation_attempts(&self) -> usize {
|
||||||
|
*self.mutation_attempts.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TemplateToolProvider for FakeTemplateTools {
|
||||||
|
async fn handle_template_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_requester: &str,
|
||||||
|
name: &str,
|
||||||
|
_arguments: Value,
|
||||||
|
) -> Result<Value, TemplateToolError> {
|
||||||
|
self.calls.lock().unwrap().push(name.to_owned());
|
||||||
|
match name {
|
||||||
|
"idea_template_list" => Ok(json!({ "items": [] })),
|
||||||
|
"idea_template_read" => Ok(json!({
|
||||||
|
"id": Uuid::from_u128(7).to_string(),
|
||||||
|
"name": "Seeded template",
|
||||||
|
"contentMd": "body",
|
||||||
|
"version": 1,
|
||||||
|
"defaultProfileId": Uuid::from_u128(9).to_string()
|
||||||
|
})),
|
||||||
|
_ => {
|
||||||
|
*self.mutation_attempts.lock().unwrap() += 1;
|
||||||
|
Err(TemplateToolError::new(
|
||||||
|
"unexpectedMutation",
|
||||||
|
format!("unexpected mutable template tool {name}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TicketToolProvider for FakeTicketTools {
|
impl TicketToolProvider for FakeTicketTools {
|
||||||
async fn handle_ticket_tool(
|
async fn handle_ticket_tool(
|
||||||
@ -644,6 +692,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
"idea_sprint_list",
|
"idea_sprint_list",
|
||||||
|
// Public template tools.
|
||||||
|
"idea_template_list",
|
||||||
|
"idea_template_read",
|
||||||
|
"idea_template_create",
|
||||||
|
"idea_template_update",
|
||||||
|
"idea_template_delete",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -653,8 +707,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
25,
|
30,
|
||||||
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
"exactly the thirty exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// Every tool advertises an object input schema.
|
||||||
@ -758,10 +812,15 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
|
|||||||
|
|
||||||
assert!(names.contains(&"idea_memory_read"));
|
assert!(names.contains(&"idea_memory_read"));
|
||||||
assert!(names.contains(&"idea_ticket_list"));
|
assert!(names.contains(&"idea_ticket_list"));
|
||||||
|
assert!(names.contains(&"idea_template_list"));
|
||||||
|
assert!(names.contains(&"idea_template_read"));
|
||||||
assert!(!names.contains(&"idea_memory_write"));
|
assert!(!names.contains(&"idea_memory_write"));
|
||||||
assert!(!names.contains(&"idea_ask_agent"));
|
assert!(!names.contains(&"idea_ask_agent"));
|
||||||
assert!(!names.contains(&"idea_ticket_update_carnet"));
|
assert!(!names.contains(&"idea_ticket_update_carnet"));
|
||||||
assert!(!names.contains(&"idea_run_in_background"));
|
assert!(!names.contains(&"idea_run_in_background"));
|
||||||
|
assert!(!names.contains(&"idea_template_create"));
|
||||||
|
assert!(!names.contains(&"idea_template_update"));
|
||||||
|
assert!(!names.contains(&"idea_template_delete"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -774,14 +833,22 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
contexts.seed_agent("architect");
|
contexts.seed_agent("architect");
|
||||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||||
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
|
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
|
||||||
.with_ticket_tools(ticket_tools.clone())
|
.with_ticket_tools(ticket_tools.clone())
|
||||||
|
.with_template_tools(template_tools.clone())
|
||||||
.for_requester(agent.to_string());
|
.for_requester(agent.to_string());
|
||||||
|
|
||||||
for (id, tool, arguments) in [
|
for (id, tool, arguments) in [
|
||||||
(101, "idea_memory_read", json!({})),
|
(101, "idea_memory_read", json!({})),
|
||||||
(102, "idea_ticket_list", json!({})),
|
(102, "idea_ticket_list", json!({})),
|
||||||
|
(103, "idea_template_list", json!({})),
|
||||||
|
(
|
||||||
|
104,
|
||||||
|
"idea_template_read",
|
||||||
|
json!({ "templateId": Uuid::from_u128(7) }),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let response = server
|
let response = server
|
||||||
.handle_raw(&tools_call(id, tool, arguments))
|
.handle_raw(&tools_call(id, tool, arguments))
|
||||||
@ -816,6 +883,21 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
json!({ "label": "task", "command": "echo" }),
|
json!({ "label": "task", "command": "echo" }),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
115,
|
||||||
|
"idea_template_create",
|
||||||
|
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
116,
|
||||||
|
"idea_template_update",
|
||||||
|
json!({ "templateId": Uuid::new_v4(), "content": "body" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
117,
|
||||||
|
"idea_template_delete",
|
||||||
|
json!({ "templateId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let response = server
|
let response = server
|
||||||
.handle_raw(&tools_call(id, tool, arguments))
|
.handle_raw(&tools_call(id, tool, arguments))
|
||||||
@ -837,6 +919,15 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
|||||||
"denied ticket mutation must not reach the provider"
|
"denied ticket mutation must not reach the provider"
|
||||||
);
|
);
|
||||||
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
template_tools.calls(),
|
||||||
|
vec![
|
||||||
|
"idea_template_list".to_owned(),
|
||||||
|
"idea_template_read".to_owned()
|
||||||
|
],
|
||||||
|
"denied template mutations must not reach the provider"
|
||||||
|
);
|
||||||
|
assert_eq!(template_tools.mutation_attempts(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -914,6 +1005,67 @@ async fn durable_agent_override_allows_an_explicit_write_tool() {
|
|||||||
assert_eq!(ticket_tools.mutation_attempts(), 1);
|
assert_eq!(ticket_tools.mutation_attempts(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn durable_agent_override_allows_only_explicit_template_write_tool() {
|
||||||
|
let (service, _s) = build_service(FakeContexts::new());
|
||||||
|
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||||
|
let agent = AgentId::from_uuid(Uuid::from_u128(85));
|
||||||
|
let template_id = Uuid::from_u128(7);
|
||||||
|
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_template_update"]))
|
||||||
|
.with_template_tools(template_tools.clone())
|
||||||
|
.for_requester(agent.to_string());
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.handle_raw(&tools_call(
|
||||||
|
132,
|
||||||
|
"idea_template_update",
|
||||||
|
json!({ "templateId": template_id, "content": "body" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("reply owed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.error.is_none(),
|
||||||
|
"durable override should pass MCP policy, got {:?}",
|
||||||
|
response.error
|
||||||
|
);
|
||||||
|
let result = response.result.expect("tool result");
|
||||||
|
assert_eq!(
|
||||||
|
result["isError"],
|
||||||
|
json!(true),
|
||||||
|
"fake provider reports execution error after policy passes"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (id, tool, arguments) in [
|
||||||
|
(
|
||||||
|
133,
|
||||||
|
"idea_template_create",
|
||||||
|
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
134,
|
||||||
|
"idea_template_delete",
|
||||||
|
json!({ "templateId": template_id }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = server
|
||||||
|
.handle_raw(&tools_call(id, tool, arguments))
|
||||||
|
.await
|
||||||
|
.expect("reply owed");
|
||||||
|
let error = response.error.expect("non-allowlisted write rejected");
|
||||||
|
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||||
|
assert!(
|
||||||
|
error.message.contains(tool),
|
||||||
|
"message should name rejected tool; got {}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
assert!(response.result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(template_tools.calls(), vec!["idea_template_update"]);
|
||||||
|
assert_eq!(template_tools.mutation_attempts(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
|
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
|
||||||
let (service, _s) = build_service(FakeContexts::new());
|
let (service, _s) = build_service(FakeContexts::new());
|
||||||
|
|||||||
Reference in New Issue
Block a user