diff --git a/.ideai/mcp-tool-permissions.json b/.ideai/mcp-tool-permissions.json index 654ca78..81552fd 100644 --- a/.ideai/mcp-tool-permissions.json +++ b/.ideai/mcp-tool-permissions.json @@ -31,82 +31,11 @@ "idea_template_create", "idea_template_update", "idea_template_delete", - "idea_create_skill" + "idea_create_skill", + "idea_ask_agents" ] }, "agents": [ - { - "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", - "policy": { - "allowedTools": [ - "idea_list_agents", - "idea_context_read", - "idea_memory_read", - "idea_skill_read", - "idea_workstate_read", - "idea_ticket_read", - "idea_ticket_list", - "idea_ticket_read_carnet", - "idea_sprint_list", - "idea_template_list", - "idea_template_read", - "idea_ask_agent", - "idea_launch_agent", - "idea_stop_agent", - "idea_update_context", - "idea_context_propose", - "idea_memory_write", - "idea_workstate_set", - "idea_create_skill", - "idea_ticket_create", - "idea_ticket_update", - "idea_ticket_update_status", - "idea_ticket_update_priority", - "idea_ticket_update_carnet", - "idea_ticket_link", - "idea_ticket_unlink", - "idea_template_create", - "idea_template_update", - "idea_template_delete" - ] - } - }, - { - "agentId": "dce19c75-9669-4e45-b8de-9950025157da", - "policy": { - "allowedTools": [ - "idea_list_agents", - "idea_context_read", - "idea_memory_read", - "idea_skill_read", - "idea_workstate_read", - "idea_ticket_read", - "idea_ticket_list", - "idea_ticket_read_carnet", - "idea_sprint_list", - "idea_template_list", - "idea_template_read", - "idea_ask_agent", - "idea_launch_agent", - "idea_stop_agent", - "idea_update_context", - "idea_context_propose", - "idea_memory_write", - "idea_workstate_set", - "idea_create_skill", - "idea_ticket_create", - "idea_ticket_update", - "idea_ticket_update_status", - "idea_ticket_update_priority", - "idea_ticket_update_carnet", - "idea_ticket_link", - "idea_ticket_unlink", - "idea_template_create", - "idea_template_update", - "idea_template_delete" - ] - } - }, { "agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", "policy": { @@ -358,6 +287,80 @@ "idea_template_delete" ] } + }, + { + "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", + "policy": { + "allowedTools": [ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", + "idea_template_list", + "idea_template_read", + "idea_ask_agent", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_context_propose", + "idea_memory_write", + "idea_workstate_set", + "idea_create_skill", + "idea_ticket_create", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", + "idea_template_create", + "idea_template_update", + "idea_template_delete", + "idea_run_in_background" + ] + } + }, + { + "agentId": "dce19c75-9669-4e45-b8de-9950025157da", + "policy": { + "allowedTools": [ + "idea_list_agents", + "idea_context_read", + "idea_memory_read", + "idea_skill_read", + "idea_workstate_read", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_read_carnet", + "idea_sprint_list", + "idea_template_list", + "idea_template_read", + "idea_ask_agent", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_context_propose", + "idea_memory_write", + "idea_workstate_set", + "idea_create_skill", + "idea_ticket_create", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", + "idea_template_create", + "idea_template_update", + "idea_template_delete", + "idea_run_in_background" + ] + } } ] } diff --git a/.ideai/tickets/112/carnet.md b/.ideai/tickets/112/carnet.md index dd83c46..be56c71 100644 --- a/.ideai/tickets/112/carnet.md +++ b/.ideai/tickets/112/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#112" -version: 3 +version: 4 updatedBy: {"kind":"user"} -updatedAt: 1785332227989 +updatedAt: 1785341341049 --- diff --git a/.ideai/tickets/112/issue.md b/.ideai/tickets/112/issue.md index f876d51..aa0600a 100644 --- a/.ideai/tickets/112/issue.md +++ b/.ideai/tickets/112/issue.md @@ -2,7 +2,7 @@ id: "ff8e11d1-98f8-4c6c-b5d0-a8a087c1dbbc" number: 112 title: "Pouvoir ajouter plusieurs tickets a la fois a un sprint" -status: "open" +status: "closed" priority: "medium" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"user"} updatedBy: {"kind":"user"} createdAt: 1785332192429 -updatedAt: 1785332227989 -version: 3 +updatedAt: 1785341341049 +version: 4 --- Je veux qu'on ajoute la possibilité de set le sprint des tickets selectionnés grace a la selection multiple de ticket dans la liste des tickets. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index fa211b8..d72e1d6 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1467,7 +1467,7 @@ "issueRef": "#112", "path": "112", "title": "Pouvoir ajouter plusieurs tickets a la fois a un sprint", - "status": "open", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ @@ -1476,7 +1476,7 @@ "createdBy": { "kind": "user" }, - "updatedAt": 1785332227989 + "updatedAt": 1785341341049 } ] } \ No newline at end of file diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 939a612..177c8ef 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -597,6 +597,39 @@ mod tests { .unwrap() } + fn default_and_override_doc( + agent: AgentId, + project_default_tools: &[&str], + override_tools: &[&str], + ) -> ProjectMcpToolPermissions { + let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); + ProjectMcpToolPermissions::new( + Some( + McpToolPolicy::new( + project_default_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + &known_tools, + ) + .unwrap(), + ), + vec![AgentMcpToolPolicyOverride::new( + agent, + McpToolPolicy::new( + override_tools + .iter() + .map(|tool| (*tool).to_owned()) + .collect(), + &known_tools, + ) + .unwrap(), + )], + &known_tools, + ) + .unwrap() + } + #[test] fn openai_tool_policy_rejects_tool_outside_allowlist() { let policies = FakePolicies::default(); @@ -864,6 +897,41 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + #[tokio::test] + async fn openai_tools_list_keeps_project_default_tools_with_agent_override() { + let temp = std::env::temp_dir().join(format!( + "idea-openai-mcp-tools-list-override-{}", + Uuid::new_v4() + )); + let core = crate::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(183)); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(default_and_override_doc( + agent, + &["idea_memory_read", "idea_run_in_background"], + &["idea_memory_read"], + )), + Arc::new(FakeTicketTools::default()), + Arc::new(FakeTemplateTools::default()), + ); + + let tools = invoker + .tools_for_context("/tmp/project", &agent.to_string()) + .await + .expect("tools list should resolve durable policy"); + let names = tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + + assert_eq!(names, vec!["idea_run_in_background", "idea_memory_read"]); + + let _ = std::fs::remove_dir_all(temp); + } + #[tokio::test] async fn openai_durable_agent_override_allows_explicit_write_tool() { let temp = diff --git a/crates/domain/src/mcp_tool_permissions.rs b/crates/domain/src/mcp_tool_permissions.rs index af3efe8..39331eb 100644 --- a/crates/domain/src/mcp_tool_permissions.rs +++ b/crates/domain/src/mcp_tool_permissions.rs @@ -185,8 +185,8 @@ impl ProjectMcpToolPermissions { /// Resolves the effective policy for `agent_id`. /// - /// Resolution order: agent override > project default > canonical read-only - /// fallback. + /// Resolution order: project default enriched by agent override > agent + /// override > project default > canonical read-only fallback. /// /// # Errors /// [`McpToolPermissionError`] if the document or fallback set is invalid. @@ -197,12 +197,24 @@ impl ProjectMcpToolPermissions { known_tools: &[&str], ) -> Result { self.validate(known_tools)?; - if let Some(override_) = self + let override_policy = self .agents .iter() .find(|override_| override_.agent_id == agent_id) + .map(|override_| &override_.policy); + if let (Some(project_default), Some(override_policy)) = + (&self.project_default, override_policy) { - return Ok(override_.policy.clone()); + let mut allowed_tools = project_default.allowed_tools.clone(); + for tool in &override_policy.allowed_tools { + if !allowed_tools.iter().any(|allowed| allowed == tool) { + allowed_tools.push(tool.clone()); + } + } + return McpToolPolicy::new(allowed_tools, known_tools); + } + if let Some(override_policy) = override_policy { + return Ok(override_policy.clone()); } if let Some(policy) = &self.project_default { return Ok(policy.clone()); @@ -236,7 +248,7 @@ mod tests { } #[test] - fn agent_override_replaces_project_default() { + fn agent_override_extends_project_default() { let project_default = McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap(); let override_policy = @@ -250,8 +262,8 @@ mod tests { let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap(); + assert!(effective.permits("idea_memory_read")); assert!(effective.permits("idea_memory_write")); - assert!(!effective.permits("idea_memory_read")); } #[test] diff --git a/crates/infrastructure/src/session/openai_compat.rs b/crates/infrastructure/src/session/openai_compat.rs index dc30672..1d8f45e 100644 --- a/crates/infrastructure/src/session/openai_compat.rs +++ b/crates/infrastructure/src/session/openai_compat.rs @@ -700,11 +700,27 @@ mod tests { #[derive(Default)] struct FakeInvoker { calls: AtomicUsize, + tools: Mutex>, + bound_tools: Mutex>>, + } + + impl FakeInvoker { + fn with_bound_tools(tools: Vec) -> Self { + Self { + calls: AtomicUsize::new(0), + tools: Mutex::new(Vec::new()), + bound_tools: Mutex::new(Some(tools)), + } + } } #[async_trait] impl ToolInvoker for FakeInvoker { fn tools(&self) -> Vec { + let tools = self.tools.lock().expect("mutex sain"); + if !tools.is_empty() { + return tools.clone(); + } vec![ToolSpec { name: "idea_echo".to_owned(), description: "Echo".to_owned(), @@ -712,6 +728,15 @@ mod tests { }] } + async fn tools_for_bound_context(&self) -> Result, ToolInvocationError> { + Ok(self + .bound_tools + .lock() + .expect("mutex sain") + .clone() + .unwrap_or_else(|| self.tools())) + } + async fn call(&self, name: &str, args_json: &str) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); Ok(format!("{name}:{args_json}")) @@ -829,6 +854,18 @@ mod tests { path } + fn tool(name: &str) -> ToolSpec { + ToolSpec { + name: name.to_owned(), + description: format!("{name} description"), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + } + } + fn config(endpoint: String, max_tool_iterations: Option) -> HttpChatConfig { config_with_timeouts(endpoint, max_tool_iterations, 10_000, 1_000) } @@ -1055,6 +1092,44 @@ mod tests { handle.abort(); } + #[tokio::test] + async fn first_openai_request_uses_bound_tool_surface() { + let (endpoint, bodies, handle) = http_server(vec![TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#, + )]) + .await; + let invoker = Arc::new(FakeInvoker::with_bound_tools(vec![ + tool("idea_memory_read"), + tool("idea_run_in_background"), + ])); + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, Some(1)), + temp_run_dir("initial-bound-tools"), + "# system", + Some(invoker), + ) + .expect("session"); + + let _ = drain(&session, "hello").await; + + let bodies = bodies.lock().expect("mutex sain"); + let body: Value = serde_json::from_str(&bodies[0]).expect("request body JSON"); + let names = body["tools"] + .as_array() + .expect("initial request carries tools") + .iter() + .map(|tool| { + tool["function"]["name"] + .as_str() + .expect("tool function name") + .to_owned() + }) + .collect::>(); + assert_eq!(names, vec!["idea_memory_read", "idea_run_in_background"]); + handle.abort(); + } + #[tokio::test] async fn max_tool_iterations_returns_single_degraded_final() { let tool = r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{}"}}]}}]}"#; diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 85242c8..fd2100c 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -754,6 +754,9 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_ticket_bulk_delete", "idea_ticket_read_carnet", "idea_ticket_update_carnet", + "idea_ticket_attachment_add", + "idea_ticket_attachment_read", + "idea_ticket_attachment_mark_summarized", "idea_ticket_link", "idea_ticket_unlink", "idea_sprint_list", @@ -772,8 +775,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 34, - "exactly the thirty-four exposed idea_* tools; got {names:?}" + 37, + "exactly the thirty-seven exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. diff --git a/crates/infrastructure/tests/mcp_tool_permission_store.rs b/crates/infrastructure/tests/mcp_tool_permission_store.rs index 89efedd..f0fef95 100644 --- a/crates/infrastructure/tests/mcp_tool_permission_store.rs +++ b/crates/infrastructure/tests/mcp_tool_permission_store.rs @@ -110,7 +110,8 @@ async fn save_then_load_roundtrips_project_default_and_agent_override() { .effective_policy(agent, READ_ONLY_TOOLS, &classified_tool_names()) .unwrap(); assert!(effective.permits("idea_ticket_update")); - assert!(!effective.permits("idea_context_read")); + assert!(effective.permits("idea_context_read")); + assert!(effective.permits("idea_memory_read")); let path = tmp.0.join(".ideai").join("mcp-tool-permissions.json"); assert!(