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