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:
@ -32,10 +32,11 @@ use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream,
|
||||
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
|
||||
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
|
||||
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator,
|
||||
McpToolPermissionStore, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle,
|
||||
PtyPort, RemotePath, ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
@ -53,7 +54,7 @@ use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||
@ -467,6 +468,66 @@ fn build_service_with_mailbox(
|
||||
(Arc::new(service), mailbox, sessions)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeStructuredSession {
|
||||
id: SessionId,
|
||||
reply: String,
|
||||
delay_ms: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeStructuredSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
Some(format!("conv-{}", self.id))
|
||||
}
|
||||
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
if self.delay_ms > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
|
||||
}
|
||||
Ok(Box::new(std::iter::once(ReplyEvent::Final {
|
||||
content: self.reply.clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_service_with_structured(
|
||||
contexts: FakeContexts,
|
||||
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
|
||||
let (base, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let service = Arc::try_unwrap(base)
|
||||
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
|
||||
.with_structured(Arc::clone(&structured));
|
||||
(Arc::new(service), structured)
|
||||
}
|
||||
|
||||
fn insert_structured_reply(
|
||||
structured: &StructuredSessions,
|
||||
agent_id: AgentId,
|
||||
reply: &str,
|
||||
delay_ms: u64,
|
||||
) {
|
||||
structured.insert_in_project(
|
||||
project().id,
|
||||
Arc::new(FakeStructuredSession {
|
||||
id: SessionId::from_uuid(Uuid::new_v4()),
|
||||
reply: reply.to_owned(),
|
||||
delay_ms,
|
||||
}),
|
||||
agent_id,
|
||||
NodeId::from_uuid(Uuid::new_v4()),
|
||||
);
|
||||
}
|
||||
|
||||
fn server(service: Arc<OrchestratorService>) -> McpServer {
|
||||
McpServer::new(service, project())
|
||||
}
|
||||
@ -665,6 +726,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
for expected in [
|
||||
"idea_list_agents",
|
||||
"idea_ask_agent",
|
||||
"idea_ask_agents",
|
||||
"idea_run_in_background",
|
||||
"idea_launch_agent",
|
||||
"idea_stop_agent",
|
||||
@ -707,8 +769,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
assert!(!names.contains(&"idea_reply"));
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
30,
|
||||
"exactly the thirty exposed idea_* tools; got {names:?}"
|
||||
31,
|
||||
"exactly the thirty-one exposed idea_* tools; got {names:?}"
|
||||
);
|
||||
|
||||
// Every tool advertises an object input schema.
|
||||
@ -1446,6 +1508,159 @@ async fn reply_tool_is_not_exposed_over_mcp() {
|
||||
assert!(resp.result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agents_fans_out_in_parallel_and_preserves_input_order() {
|
||||
let contexts = FakeContexts::new();
|
||||
let slow_id = contexts.seed_agent("slow");
|
||||
let fast_id = contexts.seed_agent("fast");
|
||||
let (service, structured) = build_service_with_structured(contexts);
|
||||
insert_structured_reply(&structured, slow_id, "slow reply", 60);
|
||||
insert_structured_reply(&structured, fast_id, "fast reply", 0);
|
||||
let server = server(service);
|
||||
|
||||
let raw = tools_call(
|
||||
21,
|
||||
"idea_ask_agents",
|
||||
json!({
|
||||
"requests": [
|
||||
{ "target": "slow", "task": "first" },
|
||||
{ "target": "fast", "task": "second" }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"transport error: {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(result["isError"], json!(false), "got {result}");
|
||||
let payload: Value =
|
||||
serde_json::from_str(result_text(&result)).expect("ask_agents result is JSON");
|
||||
let items = payload.as_array().expect("result array");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["target"], json!("slow"));
|
||||
assert_eq!(items[0]["ok"], json!(true));
|
||||
assert_eq!(items[0]["isError"], json!(false));
|
||||
assert_eq!(items[0]["text"], json!("slow reply"));
|
||||
assert_eq!(items[1]["target"], json!("fast"));
|
||||
assert_eq!(items[1]["ok"], json!(true));
|
||||
assert_eq!(items[1]["text"], json!("fast reply"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agents_allows_partial_success() {
|
||||
let contexts = FakeContexts::new();
|
||||
let ok_id = contexts.seed_agent("ok-agent");
|
||||
let (service, structured) = build_service_with_structured(contexts);
|
||||
insert_structured_reply(&structured, ok_id, "ok reply", 0);
|
||||
let server = server(service);
|
||||
|
||||
let raw = tools_call(
|
||||
22,
|
||||
"idea_ask_agents",
|
||||
json!({
|
||||
"requests": [
|
||||
{ "target": "ok-agent", "task": "works" },
|
||||
{ "target": "missing-agent", "task": "fails" }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"wrapper should not fail transport"
|
||||
);
|
||||
let result = response.result.expect("result");
|
||||
assert_eq!(
|
||||
result["isError"],
|
||||
json!(false),
|
||||
"partial success is allowed"
|
||||
);
|
||||
|
||||
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
|
||||
let items = payload.as_array().unwrap();
|
||||
assert_eq!(items[0]["target"], json!("ok-agent"));
|
||||
assert_eq!(items[0]["ok"], json!(true));
|
||||
assert_eq!(items[0]["text"], json!("ok reply"));
|
||||
assert_eq!(items[1]["target"], json!("missing-agent"));
|
||||
assert_eq!(items[1]["ok"], json!(false));
|
||||
assert_eq!(items[1]["isError"], json!(true));
|
||||
assert!(
|
||||
items[1]["text"].as_str().unwrap().contains("missing-agent"),
|
||||
"per-target error should be surfaced; got {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agents_rejects_duplicate_targets_before_dispatch() {
|
||||
let contexts = FakeContexts::new();
|
||||
let agent_id = contexts.seed_agent("Architect");
|
||||
let (service, structured) = build_service_with_structured(contexts);
|
||||
insert_structured_reply(&structured, agent_id, "must not run", 0);
|
||||
let server = server(service);
|
||||
|
||||
let raw = tools_call(
|
||||
23,
|
||||
"idea_ask_agents",
|
||||
json!({
|
||||
"requests": [
|
||||
{ "target": "Architect", "task": "one" },
|
||||
{ "target": "architect", "task": "two" }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let response = server.handle_raw(&raw).await.expect("reply owed");
|
||||
let error = response.error.expect("duplicate validation error expected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||
assert!(
|
||||
error.message.contains("duplicate target"),
|
||||
"got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(response.result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agents_rejects_non_strict_shapes_before_dispatch() {
|
||||
let (service, _structured) = build_service_with_structured(FakeContexts::new());
|
||||
let server = server(service);
|
||||
|
||||
for (id, arguments, expected) in [
|
||||
(24, json!({}), "requests"),
|
||||
(25, json!({ "requests": [] }), "non-empty"),
|
||||
(
|
||||
26,
|
||||
json!({ "requests": [{ "target": "a", "task": "x", "extra": true }] }),
|
||||
"exactly target and task",
|
||||
),
|
||||
(
|
||||
27,
|
||||
json!({ "requests": [{ "target": "a" }] }),
|
||||
"exactly target and task",
|
||||
),
|
||||
(
|
||||
28,
|
||||
json!({ "requests": [{ "target": "", "task": "x" }] }),
|
||||
"target",
|
||||
),
|
||||
] {
|
||||
let response = server
|
||||
.handle_raw(&tools_call(id, "idea_ask_agents", arguments))
|
||||
.await
|
||||
.expect("reply owed");
|
||||
let error = response.error.expect("validation error expected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||
assert!(
|
||||
error.message.contains(expected),
|
||||
"expected message to contain `{expected}`, got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(response.result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. idea_list_agents returns the agent list inline (JSON array)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user