feat(mcp): ajoute l'outil idea_ask_agents pour la délégation parallèle (#2)
Wrapper MCP backend pur : dispatche N requêtes idea_ask_agent en parallèle et retourne un résultat par requête, dans l'ordre, en rejetant les cibles dupliquées avant dispatch. Aucun changement de domaine/application — pose la primitive nécessaire au SDK (chantier feature/sdk-integration). QA vert (ticket #2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -23,8 +23,10 @@ use std::time::Instant;
|
||||
use application::OrchestratorService;
|
||||
use domain::ports::McpToolPermissionStore;
|
||||
use domain::{
|
||||
AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, Project,
|
||||
AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource,
|
||||
OrchestratorCommand, Project,
|
||||
};
|
||||
use futures_util::future::join_all;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@ -482,6 +484,12 @@ impl McpServer {
|
||||
task_len={task_len}",
|
||||
);
|
||||
|
||||
if name == "idea_ask_agents" {
|
||||
return self
|
||||
.tools_call_ask_agents(arguments, requester_label, arg_target, started)
|
||||
.await;
|
||||
}
|
||||
|
||||
if tools::is_ticket_tool(&name) {
|
||||
let result = match &self.ticket_tools {
|
||||
Some(provider) => {
|
||||
@ -611,6 +619,65 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Comfort wrapper over several parallel `idea_ask_agent` dispatches.
|
||||
///
|
||||
/// This remains an MCP-adapter-only fan-out/fan-in: each item is validated and
|
||||
/// translated to the existing [`OrchestratorCommand::AskAgent`] shape, then sent to
|
||||
/// [`OrchestratorService::dispatch`]. Per-item dispatch failures are folded into
|
||||
/// the result array so one failed target never cancels the others.
|
||||
async fn tools_call_ask_agents(
|
||||
&self,
|
||||
arguments: Value,
|
||||
requester_label: String,
|
||||
arg_target: String,
|
||||
started: Instant,
|
||||
) -> Result<Value, JsonRpcError> {
|
||||
let requests = parse_ask_agents_requests(&arguments, &self.requester)?;
|
||||
let project = self.project.clone();
|
||||
let service = Arc::clone(&self.service);
|
||||
|
||||
let futures = requests.iter().cloned().map(|request| {
|
||||
let project = project.clone();
|
||||
let service = Arc::clone(&service);
|
||||
async move {
|
||||
let command = OrchestratorCommand::AskAgent {
|
||||
target: request.target.clone(),
|
||||
task: request.task,
|
||||
requester: request.requester,
|
||||
};
|
||||
match service.dispatch(&project, command).await {
|
||||
Ok(outcome) => AskAgentsItemResult {
|
||||
target: request.target,
|
||||
ok: true,
|
||||
text: outcome.reply.unwrap_or(outcome.detail),
|
||||
},
|
||||
Err(err) => AskAgentsItemResult {
|
||||
target: request.target,
|
||||
ok: false,
|
||||
text: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let results = join_all(futures).await;
|
||||
self.publish_processed("idea_ask_agents", true);
|
||||
let text = serde_json::to_string(
|
||||
&results
|
||||
.iter()
|
||||
.map(AskAgentsItemResult::to_value)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap_or_else(|_| "[]".to_owned());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool=idea_ask_agents 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))
|
||||
}
|
||||
|
||||
fn enforce_tool_policy(
|
||||
&self,
|
||||
policy: &AgentToolPolicy,
|
||||
@ -690,6 +757,129 @@ fn tool_result_text(text: &str, is_error: bool) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AskAgentsRequest {
|
||||
target: String,
|
||||
task: String,
|
||||
requester: Option<AgentId>,
|
||||
}
|
||||
|
||||
struct AskAgentsItemResult {
|
||||
target: String,
|
||||
ok: bool,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl AskAgentsItemResult {
|
||||
fn to_value(&self) -> Value {
|
||||
json!({
|
||||
"target": self.target,
|
||||
"ok": self.ok,
|
||||
"isError": !self.ok,
|
||||
"text": self.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ask_agents_requests(
|
||||
arguments: &Value,
|
||||
requester: &str,
|
||||
) -> Result<Vec<AskAgentsRequest>, JsonRpcError> {
|
||||
let args = arguments.as_object().ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
"tool `idea_ask_agents` arguments must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
if args.len() != 1 || !args.contains_key("requests") {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
"tool `idea_ask_agents` expects exactly one field: requests",
|
||||
));
|
||||
}
|
||||
let requests = args
|
||||
.get("requests")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
"tool `idea_ask_agents` field `requests` must be a non-empty array",
|
||||
)
|
||||
})?;
|
||||
if requests.is_empty() {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
"tool `idea_ask_agents` field `requests` must be a non-empty array",
|
||||
));
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::with_capacity(requests.len());
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
let obj = request.as_object().ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `idea_ask_agents` requests[{index}] must be an object"),
|
||||
)
|
||||
})?;
|
||||
if obj.len() != 2 || !obj.contains_key("target") || !obj.contains_key("task") {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `idea_ask_agents` requests[{index}] expects exactly target and task"),
|
||||
));
|
||||
}
|
||||
let target = obj
|
||||
.get("target")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `idea_ask_agents` requests[{index}].target must be a string"),
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
let task = obj
|
||||
.get("task")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `idea_ask_agents` requests[{index}].task must be a string"),
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
let duplicate_key = target.to_lowercase();
|
||||
if !seen.insert(duplicate_key) {
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INVALID_PARAMS,
|
||||
format!("tool `idea_ask_agents` duplicate target `{target}` is not supported"),
|
||||
));
|
||||
}
|
||||
let command = tools::map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": target, "task": task }),
|
||||
requester,
|
||||
)
|
||||
.map_err(map_err_to_jsonrpc)?;
|
||||
let OrchestratorCommand::AskAgent {
|
||||
target,
|
||||
task,
|
||||
requester,
|
||||
} = command
|
||||
else {
|
||||
unreachable!("idea_ask_agent must map to AskAgent");
|
||||
};
|
||||
out.push(AskAgentsRequest {
|
||||
target,
|
||||
task,
|
||||
requester,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
||||
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
||||
match err {
|
||||
|
||||
@ -61,6 +61,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
/// Canonical write/action MCP tools denied by default.
|
||||
pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_ask_agent",
|
||||
"idea_ask_agents",
|
||||
"idea_run_in_background",
|
||||
"idea_launch_agent",
|
||||
"idea_stop_agent",
|
||||
@ -125,6 +126,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
"idea_ask_agent"
|
||||
| "idea_ask_agents"
|
||||
| "idea_list_agents"
|
||||
| "idea_context_read"
|
||||
| "idea_memory_read"
|
||||
@ -180,6 +182,32 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ask_agents",
|
||||
description: "Ask several IdeA agents in parallel and return one result per request, \
|
||||
in the same order. Each request is equivalent to one idea_ask_agent \
|
||||
call; duplicate targets are rejected before dispatch.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Target agent display name." },
|
||||
"task": { "type": "string", "description": "Task or question to send to the target agent." }
|
||||
},
|
||||
"required": ["target", "task"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["requests"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_run_in_background",
|
||||
description: "Run a command as a first-class IdeA background task. Returns the task id \
|
||||
@ -659,6 +687,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agents_is_classified_and_returns_inline_reply() {
|
||||
assert_eq!(
|
||||
tool_access("idea_ask_agents"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert!(catalogue()
|
||||
.into_iter()
|
||||
.any(|tool| tool.name == "idea_ask_agents"));
|
||||
assert!(tool_returns_reply("idea_ask_agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_in_background_maps_to_background_command_with_handshake_owner() {
|
||||
let cmd = map_tool_call(
|
||||
|
||||
Reference in New Issue
Block a user