1use crate::{Agent, AgentConfig, AgentRegistry, AgentResponse};
2use ares_llm::LLMClient;
3use ares_types::types::{AgentContext, AgentType, AppError, Result};
4use async_trait::async_trait;
5use std::future::Future;
6use std::sync::Arc;
7
8pub struct OrchestratorAgent {
13 llm: Box<dyn LLMClient>,
14 agents: Arc<std::collections::HashMap<String, AgentConfig>>,
15 agent_registry: Arc<AgentRegistry>,
16}
17
18impl OrchestratorAgent {
19 pub fn new(
21 llm: Box<dyn LLMClient>,
22 agents: Arc<std::collections::HashMap<String, AgentConfig>>,
23 agent_registry: Arc<AgentRegistry>,
24 ) -> Self {
25 Self {
26 llm,
27 agents,
28 agent_registry,
29 }
30 }
31
32 async fn decompose_task(&self, input: &str) -> Result<Vec<(String, String)>> {
34 let available_agents = self.agent_registry.agent_names();
36 let agent_list = available_agents
37 .iter()
38 .filter(|name| **name != "orchestrator" && **name != "router")
39 .cloned()
40 .collect::<Vec<_>>()
41 .join(", ");
42
43 let system_prompt = format!(
44 r#"You are a task decomposition agent. Break down complex queries into subtasks for specialized agents.
45
46Available agents: {}
47
48Return a JSON array of tasks:
49[
50 {{"agent": "sales", "task": "Get Q1 revenue"}},
51 {{"agent": "product", "task": "List top products"}}
52]
53
54Only respond with valid JSON."#,
55 agent_list
56 );
57
58 let response = self.llm.generate_with_system(&system_prompt, input).await?;
59
60 let tasks: Vec<serde_json::Value> = serde_json::from_str(&response)
62 .map_err(|e| AppError::LLM(format!("Failed to parse tasks: {}", e)))?;
63
64 let mut result = Vec::new();
65 for task in tasks {
66 let agent_name = task["agent"].as_str().unwrap_or("product").to_string();
67 let task_str = task["task"].as_str().unwrap_or("").to_string();
68
69 if self.agent_registry.has_agent(&agent_name) {
71 result.push((agent_name, task_str));
72 } else {
73 result.push(("product".to_string(), task_str));
75 }
76 }
77
78 Ok(result)
79 }
80
81 async fn execute_subtask(
83 &self,
84 agent_name: &str,
85 task: &str,
86 context: &AgentContext,
87 ) -> Result<String> {
88 let agent = self.agent_registry.create_agent(agent_name).await?;
90 let resp = agent.execute(task, context).await?;
91 Ok(resp.content)
92 }
93}
94
95pub(crate) async fn join_subtask_results<T, E, Fut>(
98 futs: Vec<Fut>,
99) -> std::result::Result<Vec<T>, E>
100where
101 Fut: Future<Output = std::result::Result<T, E>>,
102{
103 futures::future::try_join_all(futs).await
104}
105
106#[async_trait]
107impl Agent for OrchestratorAgent {
108 async fn execute(&self, input: &str, context: &AgentContext) -> Result<AgentResponse> {
109 let subtasks = self.decompose_task(input).await?;
111
112 if subtasks.is_empty() {
113 let content = self.llm.generate(input).await?;
114 return Ok(AgentResponse { content, usage: None, metadata: None });
115 }
116
117 let futs = subtasks
119 .into_iter()
120 .map(|(agent_name, task)| async move {
121 let result = self.execute_subtask(&agent_name, &task, context).await?;
122 Ok::<_, AppError>(format!("[{}] {}", agent_name, result))
123 })
124 .collect();
125 let results = join_subtask_results(futs).await?;
126
127 let synthesis_prompt = format!(
129 "Original query: {}\n\nSubtask results:\n{}\n\nProvide a comprehensive answer:",
130 input,
131 results.join("\n\n")
132 );
133
134 let content = self.llm.generate(&synthesis_prompt).await?;
135 Ok(AgentResponse { content, usage: None, metadata: None })
136 }
137
138 fn system_prompt(&self) -> String {
139 self.agents
141 .get("orchestrator")
142 .and_then(|a| a.system_prompt.clone())
143 .unwrap_or_else(|| {
144 "You are an orchestrator agent that coordinates multiple specialized agents to answer complex queries.".to_string()
145 })
146 }
147
148 fn agent_type(&self) -> AgentType {
149 AgentType::Orchestrator
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::AgentConfig;
157 use ares_llm::{LLMClient, LLMResponse, ModelConfig, ProviderConfig, ProviderRegistry};
158 use ares_tools::{Tool, Tools};
159 use ares_types::types::ToolDefinition;
160 use async_trait::async_trait;
161 use std::collections::{HashMap, VecDeque};
162 use std::sync::{Arc, Mutex};
163
164 #[derive(Clone)]
165 struct ScriptedLlm {
166 responses: Arc<Mutex<VecDeque<String>>>,
167 system_prompts: Arc<Mutex<Vec<String>>>,
168 generate_prompts: Arc<Mutex<Vec<String>>>,
169 }
170
171 impl ScriptedLlm {
172 fn new(responses: Vec<&str>) -> Self {
173 Self {
174 responses: Arc::new(Mutex::new(responses.into_iter().map(str::to_string).collect())),
175 system_prompts: Arc::new(Mutex::new(Vec::new())),
176 generate_prompts: Arc::new(Mutex::new(Vec::new())),
177 }
178 }
179
180 fn system_prompts(&self) -> Vec<String> {
181 self.system_prompts.lock().unwrap().clone()
182 }
183
184 fn generate_prompts(&self) -> Vec<String> {
185 self.generate_prompts.lock().unwrap().clone()
186 }
187
188 fn next_response(&self) -> String {
189 self.responses
190 .lock()
191 .unwrap()
192 .pop_front()
193 .unwrap_or_else(|| "fallback-response".to_string())
194 }
195 }
196
197 #[async_trait]
198 impl LLMClient for ScriptedLlm {
199 fn model_name(&self) -> &str {
200 "scripted-test"
201 }
202 async fn generate(&self, prompt: &str) -> Result<String> {
203 self.generate_prompts.lock().unwrap().push(prompt.to_string());
204 Ok(self.next_response())
205 }
206 async fn generate_with_system(&self, system: &str, _: &str) -> Result<String> {
207 self.system_prompts.lock().unwrap().push(system.to_string());
208 Ok(self.next_response())
209 }
210 async fn generate_with_history(&self, _: &[(String, String)]) -> Result<LLMResponse> {
211 Ok(LLMResponse {
212 content: self.next_response(),
213 tool_calls: vec![],
214 finish_reason: "stop".to_string(),
215 usage: None,
216 })
217 }
218 async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
219 Ok(LLMResponse {
220 content: self.next_response(),
221 tool_calls: vec![],
222 finish_reason: "stop".to_string(),
223 usage: None,
224 })
225 }
226 async fn generate_with_tools_and_history(
227 &self,
228 _: &[ares_llm::coordinator::ConversationMessage],
229 _: &[ToolDefinition],
230 ) -> Result<LLMResponse> {
231 Ok(LLMResponse {
232 content: self.next_response(),
233 tool_calls: vec![],
234 finish_reason: "stop".to_string(),
235 usage: None,
236 })
237 }
238 async fn stream(&self, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
239 Ok(Box::new(futures::stream::empty()))
240 }
241 async fn stream_with_system(&self, _: &str, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
242 Ok(Box::new(futures::stream::empty()))
243 }
244 async fn stream_with_history(&self, _: &[(String, String)]) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
245 Ok(Box::new(futures::stream::empty()))
246 }
247 }
248
249 fn test_context() -> AgentContext {
250 AgentContext {
251 user_id: "test-user".to_string(),
252 session_id: "test-session".to_string(),
253 conversation_history: vec![],
254 user_memory: None,
255 }
256 }
257
258 fn sample_agent_config() -> AgentConfig {
259 AgentConfig {
260 model: "default".to_string(),
261 system_prompt: None,
262 tools: vec![],
263 max_tool_iterations: 10,
264 parallel_tools: false,
265 allowed_tools: None,
266 extra: HashMap::new(),
267 compaction_enabled: None,
268 }
269 }
270
271 fn create_test_agent_map(agents: HashMap<String, AgentConfig>) -> HashMap<String, AgentConfig> {
272 agents
273 }
274
275 fn create_test_provider_registry() -> Arc<ProviderRegistry> {
276 let mut registry = ProviderRegistry::new();
277 registry.register_provider(
278 "ollama-local",
279 ProviderConfig::Ollama {
280 api_key_env: "TEST_KEY".to_string(),
281 base_url: "https://test.example.com".to_string(),
282 default_model: "ministral-3:3b".to_string(),
283 },
284 );
285 registry.register_model(
286 "default",
287 ModelConfig {
288 provider: "ollama-local".to_string(),
289 model: "ministral-3:3b".to_string(),
290 temperature: 0.7,
291 max_tokens: 512,
292 },
293 );
294 Arc::new(registry)
295 }
296
297
298 fn create_test_provider_registry_with_base_url(base_url: &str) -> Arc<ProviderRegistry> {
299 let mut registry = ProviderRegistry::new();
300 registry.register_provider(
301 "ollama-local",
302 ProviderConfig::Ollama {
303 api_key_env: "TEST_KEY".to_string(),
304 base_url: base_url.to_string(),
305 default_model: "ministral-3:3b".to_string(),
306 },
307 );
308 registry.register_model(
309 "default",
310 ModelConfig {
311 provider: "ollama-local".to_string(),
312 model: "ministral-3:3b".to_string(),
313 temperature: 0.7,
314 max_tokens: 512,
315 },
316 );
317 Arc::new(registry)
318 }
319
320 fn build_registry_with_provider(agent_names: &[&str], base_url: &str) -> Arc<AgentRegistry> {
321 let provider_registry = create_test_provider_registry_with_base_url(base_url);
322 let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
323 let mut registry = AgentRegistry::new(provider_registry, tools);
324 for name in agent_names {
325 registry.register(name, sample_agent_config());
326 }
327 registry.register("orchestrator", sample_agent_config());
328 registry.register("router", sample_agent_config());
329 Arc::new(registry)
330 }
331
332 fn chat_done_json(content: &str) -> String {
333 serde_json::json!({
334 "model": "test-model",
335 "created_at": "2024-01-01T00:00:00Z",
336 "message": { "role": "assistant", "content": content },
337 "done": true
338 })
339 .to_string()
340 }
341
342 fn build_registry(agent_names: &[&str]) -> Arc<AgentRegistry> {
343 let provider_registry = create_test_provider_registry();
344 let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
345 let mut registry = AgentRegistry::new(provider_registry, tools);
346 for name in agent_names {
347 registry.register(name, sample_agent_config());
348 }
349 registry.register("orchestrator", sample_agent_config());
350 registry.register("router", sample_agent_config());
351 Arc::new(registry)
352 }
353
354 fn build_orchestrator(llm: ScriptedLlm, agents: &[&str], config: HashMap<String, AgentConfig>) -> OrchestratorAgent {
355 OrchestratorAgent::new(
356 Box::new(llm),
357 Arc::new(config),
358 build_registry(agents),
359 )
360 }
361
362 #[tokio::test]
363 async fn test_decompose_task_parses_valid_json() {
364 let orch = build_orchestrator(
365 ScriptedLlm::new(vec![r#"[{"agent":"sales","task":"Get Q1 revenue"},{"agent":"product","task":"Top SKUs"}]"#]),
366 &["sales", "product"],
367 create_test_agent_map(HashMap::new()),
368 );
369 let tasks = orch.decompose_task("Quarterly business review").await.expect("decompose");
370 assert_eq!(tasks.len(), 2);
371 assert_eq!(tasks[0], ("sales".to_string(), "Get Q1 revenue".to_string()));
372 assert_eq!(tasks[1], ("product".to_string(), "Top SKUs".to_string()));
373 }
374
375 #[tokio::test]
376 async fn test_decompose_task_unknown_agent_falls_back_to_product() {
377 let orch = build_orchestrator(
378 ScriptedLlm::new(vec![r#"[{"agent":"unknown-agent","task":"Do something"}]"#]),
379 &["product"],
380 create_test_agent_map(HashMap::new()),
381 );
382 let tasks = orch.decompose_task("task").await.expect("decompose");
383 assert_eq!(tasks, vec![("product".to_string(), "Do something".to_string())]);
384 }
385
386 #[tokio::test]
387 async fn test_decompose_task_invalid_json_errors() {
388 let orch = build_orchestrator(
389 ScriptedLlm::new(vec!["not-json"]),
390 &["product"],
391 create_test_agent_map(HashMap::new()),
392 );
393 let err = orch.decompose_task("task").await.unwrap_err();
394 assert!(matches!(err, AppError::LLM(_)));
395 }
396
397 #[tokio::test]
398 async fn test_decompose_task_excludes_orchestrator_and_router_from_prompt() {
399 let llm = ScriptedLlm::new(vec![r#"[]"#]);
400 let llm_clone = llm.clone();
401 let orch = build_orchestrator(llm, &["sales", "product"], create_test_agent_map(HashMap::new()));
402 orch.decompose_task("plan").await.expect("decompose");
403 let system = llm_clone.system_prompts().into_iter().next().expect("system prompt");
404 assert!(system.contains("sales"));
405 assert!(system.contains("product"));
406 assert!(!system.contains("orchestrator"));
407 assert!(!system.contains("router"));
408 }
409
410 #[tokio::test]
411 async fn test_execute_with_no_subtasks_uses_direct_generation() {
412 let orch = build_orchestrator(
413 ScriptedLlm::new(vec![r#"[]"#, "direct-answer"]),
414 &["product"],
415 create_test_agent_map(HashMap::new()),
416 );
417 let resp = orch.execute("simple question", &test_context()).await.expect("execute");
418 assert_eq!(resp.content, "direct-answer");
419 }
420
421 #[tokio::test]
422 async fn test_execute_subtasks_run_concurrently() {
423 use std::time::{Duration, Instant};
424
425 async fn sleep_ok(label: &'static str) -> std::result::Result<&'static str, &'static str> {
426 tokio::time::sleep(Duration::from_millis(80)).await;
427 Ok(label)
428 }
429
430 let start = Instant::now();
431 let results = join_subtask_results(vec![sleep_ok("a"), sleep_ok("b")])
432 .await
433 .expect("join");
434 let elapsed = start.elapsed();
435 assert_eq!(results, vec!["a", "b"]);
436 assert!(
437 elapsed < Duration::from_millis(140),
438 "expected concurrent join under 140ms, got {elapsed:?}"
439 );
440 }
441
442 #[test]
443 fn test_system_prompt_from_config() {
444 let mut agents = HashMap::new();
445 agents.insert(
446 "orchestrator".to_string(),
447 AgentConfig {
448 model: "default".to_string(),
449 system_prompt: Some("Custom orchestrator prompt".to_string()),
450 tools: vec![],
451 max_tool_iterations: 10,
452 parallel_tools: false,
453 allowed_tools: None,
454 extra: HashMap::new(),
455 compaction_enabled: None,
456 },
457 );
458 let orch = build_orchestrator(ScriptedLlm::new(vec![]), &[], create_test_agent_map(agents));
459 assert_eq!(orch.system_prompt(), "Custom orchestrator prompt");
460 }
461
462 #[test]
463 fn test_system_prompt_default_when_missing() {
464 let orch = build_orchestrator(ScriptedLlm::new(vec![]), &[], create_test_agent_map(HashMap::new()));
465 assert!(orch.system_prompt().contains("orchestrator agent"));
466 }
467
468 #[test]
469 fn test_agent_type_is_orchestrator() {
470 let orch = build_orchestrator(ScriptedLlm::new(vec![]), &[], create_test_agent_map(HashMap::new()));
471 assert_eq!(orch.agent_type(), AgentType::Orchestrator);
472 }
473
474 #[tokio::test]
475 async fn test_execute_with_subtasks_delegates_and_synthesizes() {
476 use wiremock::matchers::{method, path};
477 use wiremock::{Mock, MockServer, ResponseTemplate};
478
479 let server = MockServer::start().await;
480 Mock::given(method("POST"))
481 .and(path("/api/chat"))
482 .respond_with(
483 ResponseTemplate::new(200).set_body_string(chat_done_json("sales-agent-output")),
484 )
485 .mount(&server)
486 .await;
487
488 let llm = ScriptedLlm::new(vec![
489 r#"[{"agent":"sales","task":"Get Q1 revenue"}]"#,
490 "synthesized final answer",
491 ]);
492 let registry = build_registry_with_provider(&["sales"], &server.uri());
493 let orch = OrchestratorAgent::new(
494 Box::new(llm),
495 Arc::new(create_test_agent_map(HashMap::new())),
496 registry,
497 );
498
499 let resp = orch
500 .execute("quarterly business review", &test_context())
501 .await
502 .expect("execute with subtasks");
503 assert_eq!(resp.content, "synthesized final answer");
504 }
505
506 #[tokio::test]
507 async fn test_decompose_task_defaults_missing_json_fields() {
508 let orch = build_orchestrator(
509 ScriptedLlm::new(vec![r#"[{"task":"task-only"},{"agent":"sales"}]"#]),
510 &["sales", "product"],
511 create_test_agent_map(HashMap::new()),
512 );
513 let tasks = orch.decompose_task("plan").await.expect("decompose");
514 assert_eq!(
515 tasks,
516 vec![
517 ("product".to_string(), "task-only".to_string()),
518 ("sales".to_string(), String::new()),
519 ]
520 );
521 }
522
523 #[tokio::test]
524 async fn test_decompose_task_mixed_known_and_unknown_agents() {
525 let orch = build_orchestrator(
526 ScriptedLlm::new(vec![
527 r#"[{"agent":"sales","task":"Revenue"},{"agent":"ghost","task":"Haunt"},{"agent":"finance","task":"Budget"}]"#,
528 ]),
529 &["sales", "finance", "product"],
530 create_test_agent_map(HashMap::new()),
531 );
532 let tasks = orch.decompose_task("mixed").await.expect("decompose");
533 assert_eq!(
534 tasks,
535 vec![
536 ("sales".to_string(), "Revenue".to_string()),
537 ("product".to_string(), "Haunt".to_string()),
538 ("finance".to_string(), "Budget".to_string()),
539 ]
540 );
541 }
542
543 #[tokio::test]
544 async fn test_decompose_task_only_orchestrator_router_yields_empty_agent_list() {
545 let llm = ScriptedLlm::new(vec![r#"[]"#]);
546 let llm_clone = llm.clone();
547 let orch = build_orchestrator(llm, &[], create_test_agent_map(HashMap::new()));
548 orch.decompose_task("plan").await.expect("decompose");
549 let system = llm_clone.system_prompts().into_iter().next().expect("system prompt");
550 assert!(system.contains("Available agents: "));
551 assert!(!system.contains("orchestrator"));
552 assert!(!system.contains("router"));
553 }
554
555 #[tokio::test]
556 async fn test_execute_subtask_returns_registered_agent_content() {
557 use wiremock::matchers::{method, path};
558 use wiremock::{Mock, MockServer, ResponseTemplate};
559
560 let server = MockServer::start().await;
561 Mock::given(method("POST"))
562 .and(path("/api/chat"))
563 .respond_with(ResponseTemplate::new(200).set_body_string(chat_done_json("subtask-body")))
564 .mount(&server)
565 .await;
566
567 let orch = OrchestratorAgent::new(
568 Box::new(ScriptedLlm::new(vec![])),
569 Arc::new(create_test_agent_map(HashMap::new())),
570 build_registry_with_provider(&["sales"], &server.uri()),
571 );
572 let content = orch
573 .execute_subtask("sales", "Get Q1 revenue", &test_context())
574 .await
575 .expect("subtask");
576 assert_eq!(content, "subtask-body");
577 }
578
579 #[tokio::test]
580 async fn test_execute_subtask_unknown_agent_errors() {
581 let orch = build_orchestrator(
582 ScriptedLlm::new(vec![]),
583 &["product"],
584 create_test_agent_map(HashMap::new()),
585 );
586 let err = orch
587 .execute_subtask("missing-agent", "task", &test_context())
588 .await
589 .unwrap_err();
590 assert!(!matches!(err, AppError::LLM(_)));
591 }
592
593 #[tokio::test]
594 async fn test_execute_multiple_subtasks_formats_results_for_synthesis() {
595 use wiremock::matchers::{method, path};
596 use wiremock::{Mock, MockServer, ResponseTemplate};
597
598 let server = MockServer::start().await;
599 Mock::given(method("POST"))
600 .and(path("/api/chat"))
601 .respond_with(ResponseTemplate::new(200).set_body_string(chat_done_json("agent-output")))
602 .mount(&server)
603 .await;
604
605 let llm = ScriptedLlm::new(vec![
606 r#"[{"agent":"sales","task":"Revenue"},{"agent":"finance","task":"Budget"}]"#,
607 "combined answer",
608 ]);
609 let llm_clone = llm.clone();
610 let orch = OrchestratorAgent::new(
611 Box::new(llm),
612 Arc::new(create_test_agent_map(HashMap::new())),
613 build_registry_with_provider(&["sales", "finance"], &server.uri()),
614 );
615
616 let resp = orch
617 .execute("annual review", &test_context())
618 .await
619 .expect("execute");
620 assert_eq!(resp.content, "combined answer");
621 let synthesis = llm_clone.generate_prompts().into_iter().next().expect("synthesis prompt");
622 assert!(synthesis.contains("Original query: annual review"));
623 assert!(synthesis.contains("[sales] agent-output"));
624 assert!(synthesis.contains("[finance] agent-output"));
625 assert!(synthesis.contains("Subtask results:"));
626 }
627
628 #[test]
629 fn test_agent_type_orchestrator_serde_roundtrip() {
630 let agent = AgentType::Orchestrator;
631 let json = serde_json::to_string(&agent).expect("serialize");
632 assert_eq!(json, "\"orchestrator\"");
633 let parsed: AgentType = serde_json::from_str(&json).expect("deserialize");
634 assert_eq!(parsed, agent);
635 assert_eq!(format!("{parsed:?}"), "Orchestrator");
636 assert_eq!(agent.clone(), AgentType::Orchestrator);
637 }
638
639 #[test]
640 fn test_agent_context_debug_clone() {
641 let ctx = test_context();
642 let cloned = ctx.clone();
643 assert_eq!(cloned.user_id, "test-user");
644 assert_eq!(cloned.session_id, "test-session");
645 assert!(format!("{ctx:?}").contains("AgentContext"));
646 }
647
648 #[test]
649 fn test_agent_response_none_usage_and_metadata() {
650 let resp = AgentResponse {
651 content: "ok".to_string(),
652 usage: None,
653 metadata: None,
654 };
655 assert_eq!(resp.content, "ok");
656 assert!(resp.usage.is_none());
657 assert!(resp.metadata.is_none());
658 }
659
660
661}