1use std::future::Future;
4use std::pin::Pin;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use tracing::{info, info_span};
11
12use crate::error::Error;
13use crate::llm::types::{TokenUsage, ToolDefinition};
14use crate::llm::{BoxedProvider, LlmProvider};
15use crate::tool::{Tool, ToolOutput};
16use crate::types::DispatchMode;
17
18use crate::memory::Memory;
19
20use crate::knowledge::KnowledgeBase;
21
22use crate::tool::builtins::OnQuestion;
23
24use super::blackboard::{Blackboard, InMemoryBlackboard};
25use super::blackboard_tools::blackboard_tools;
26use super::context::ContextStrategy;
27use super::events::{AgentEvent, OnEvent};
28use super::guardrail::Guardrail;
29use super::{AgentOutput, AgentRunner};
30
31#[derive(Clone)]
33pub(crate) struct SubAgentDef {
34 pub(crate) name: String,
35 pub(crate) description: String,
36 pub(crate) system_prompt: String,
37 pub(crate) tools: Vec<Arc<dyn Tool>>,
38 pub(crate) context_strategy: Option<ContextStrategy>,
39 pub(crate) summarize_threshold: Option<u32>,
40 pub(crate) tool_timeout: Option<Duration>,
41 pub(crate) max_tool_output_bytes: Option<usize>,
42 pub(crate) max_turns: Option<usize>,
44 pub(crate) max_tokens: Option<u32>,
46 pub(crate) response_schema: Option<serde_json::Value>,
48 pub(crate) guardrails: Vec<Arc<dyn Guardrail>>,
50 pub(crate) run_timeout: Option<Duration>,
52 pub(crate) provider_override: Option<Arc<BoxedProvider>>,
55 pub(crate) reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
57 pub(crate) enable_reflection: Option<bool>,
59 pub(crate) tool_output_compression_threshold: Option<usize>,
61 pub(crate) max_tools_per_turn: Option<usize>,
63 pub(crate) tool_profile: Option<super::tool_filter::ToolProfile>,
65 pub(crate) max_identical_tool_calls: Option<u32>,
67 pub(crate) max_fuzzy_identical_tool_calls: Option<u32>,
69 pub(crate) max_tool_calls_per_turn: Option<u32>,
71 pub(crate) session_prune_config: Option<crate::agent::pruner::SessionPruneConfig>,
73 pub(crate) enable_recursive_summarization: Option<bool>,
75 pub(crate) reflection_threshold: Option<u32>,
77 pub(crate) consolidate_on_exit: Option<bool>,
79 pub(crate) workspace: Option<std::path::PathBuf>,
81 pub(crate) max_total_tokens: Option<u64>,
83 pub(crate) audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
85 pub(crate) audit_user_id: Option<String>,
87 pub(crate) audit_tenant_id: Option<String>,
89 pub(crate) audit_delegation_chain: Vec<String>,
91 pub(crate) context: SubAgentContextConfig,
93}
94
95impl std::fmt::Debug for SubAgentDef {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("SubAgentDef")
98 .field("name", &self.name)
99 .field("description", &self.description)
100 .field("tools_count", &self.tools.len())
101 .finish()
102 }
103}
104
105impl SubAgentDef {
106 pub(crate) fn new(
109 name: impl Into<String>,
110 description: impl Into<String>,
111 system_prompt: impl Into<String>,
112 ) -> Self {
113 Self {
114 name: name.into(),
115 description: description.into(),
116 system_prompt: system_prompt.into(),
117 tools: vec![],
118 context_strategy: None,
119 summarize_threshold: None,
120 tool_timeout: None,
121 max_tool_output_bytes: None,
122 max_turns: None,
123 max_tokens: None,
124 response_schema: None,
125 run_timeout: None,
126 guardrails: vec![],
127 provider_override: None,
128 reasoning_effort: None,
129 enable_reflection: None,
130 tool_output_compression_threshold: None,
131 max_tools_per_turn: None,
132 tool_profile: None,
133 max_identical_tool_calls: None,
134 max_fuzzy_identical_tool_calls: None,
135 max_tool_calls_per_turn: None,
136 session_prune_config: None,
137 enable_recursive_summarization: None,
138 reflection_threshold: None,
139 consolidate_on_exit: None,
140 workspace: None,
141 max_total_tokens: None,
142 audit_trail: None,
143 audit_user_id: None,
144 audit_tenant_id: None,
145 audit_delegation_chain: Vec::new(),
146 context: SubAgentContextConfig::default(),
147 }
148 }
149}
150
151impl From<SubAgentConfig> for SubAgentDef {
152 fn from(def: SubAgentConfig) -> Self {
153 Self {
154 name: def.name,
155 description: def.description,
156 system_prompt: def.system_prompt,
157 tools: def.tools,
158 context_strategy: def.context_strategy,
159 summarize_threshold: def.summarize_threshold,
160 tool_timeout: def.tool_timeout,
161 max_tool_output_bytes: def.max_tool_output_bytes,
162 max_turns: def.max_turns,
163 max_tokens: def.max_tokens,
164 response_schema: def.response_schema,
165 run_timeout: def.run_timeout,
166 guardrails: def.guardrails,
167 provider_override: def.provider,
168 reasoning_effort: def.reasoning_effort,
169 enable_reflection: def.enable_reflection,
170 tool_output_compression_threshold: def.tool_output_compression_threshold,
171 max_tools_per_turn: def.max_tools_per_turn,
172 tool_profile: def.tool_profile,
173 max_identical_tool_calls: def.max_identical_tool_calls,
174 max_fuzzy_identical_tool_calls: def.max_fuzzy_identical_tool_calls,
175 max_tool_calls_per_turn: def.max_tool_calls_per_turn,
176 session_prune_config: def.session_prune_config,
177 enable_recursive_summarization: def.enable_recursive_summarization,
178 reflection_threshold: def.reflection_threshold,
179 consolidate_on_exit: def.consolidate_on_exit,
180 workspace: def.workspace,
181 max_total_tokens: def.max_total_tokens,
182 audit_trail: def.audit_trail,
183 audit_user_id: def.audit_user_id,
184 audit_tenant_id: def.audit_tenant_id,
185 audit_delegation_chain: def.audit_delegation_chain,
186 context: def.context,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub(crate) struct DelegatedTask {
194 pub(crate) agent: String,
195 pub(crate) task: String,
196}
197
198#[derive(Debug, Clone)]
200pub(crate) struct SubAgentResult {
201 pub(crate) agent: String,
202 pub(crate) result: String,
203 pub(crate) tokens_used: TokenUsage,
204 pub(crate) success: bool,
205}
206
207pub struct Orchestrator<P: LlmProvider> {
217 runner: AgentRunner<P>,
218 sub_agent_tokens: Arc<Mutex<TokenUsage>>,
220}
221
222impl<P: LlmProvider + 'static> Orchestrator<P> {
223 pub fn builder(provider: Arc<P>) -> OrchestratorBuilder<P> {
225 OrchestratorBuilder {
226 provider,
227 sub_agents: vec![],
228 max_turns: 10,
229 max_tokens: 4096,
230 context_strategy: None,
231 summarize_threshold: None,
232 tool_timeout: None,
233 max_tool_output_bytes: None,
234 shared_memory: None,
235 memory_namespace_prefix: None,
236 blackboard: None,
237 knowledge_base: None,
238 on_text: None,
239 on_reasoning: None,
240 on_approval: None,
241 on_input: None,
242 interrupt: None,
243 on_event: None,
244 guardrails: Vec::new(),
245 on_question: None,
246 run_timeout: None,
247 enable_squads: None,
248 reasoning_effort: None,
249 enable_reflection: false,
250 tool_output_compression_threshold: None,
251 max_tools_per_turn: None,
252 max_identical_tool_calls: None,
253 max_fuzzy_identical_tool_calls: None,
254 max_tool_calls_per_turn: None,
255 permission_rules: super::permission::PermissionRuleset::default(),
256 instruction_text: None,
257 learned_permissions: None,
258 lsp_manager: None,
259 observability_mode: None,
260 dispatch_mode: DispatchMode::Parallel,
261 workspace: None,
262 audit_trail: None,
263 audit_user_id: None,
264 audit_tenant_id: None,
265 audit_delegation_chain: Vec::new(),
266 allow_shared_write: true,
267 multi_agent_prompt: true,
268 spawn_config: None,
269 spawn_builtin_tools: Vec::new(),
270 tenant_tracker: None,
271 entry_mode: false,
272 entry_direct_tools: Vec::new(),
273 entry_workflow_recipes: Vec::new(),
274 entry_context: SubAgentContextConfig::default(),
275 entry_goal: None,
276 entry_goal_judge: None,
277 entry_request_router: None,
278 }
279 }
280
281 pub async fn run(&mut self, task: &str) -> Result<AgentOutput, Error> {
292 {
294 let mut acc = self.sub_agent_tokens.lock().expect("token lock poisoned");
295 *acc = TokenUsage::default();
296 }
297 match self.runner.execute(task).await {
298 Ok(mut output) => {
299 let sub_tokens = *self.sub_agent_tokens.lock().expect("token lock poisoned");
301 output.tokens_used += sub_tokens;
302 Ok(output)
303 }
304 Err(e) => {
305 let sub_tokens = *self.sub_agent_tokens.lock().expect("token lock poisoned");
308 let mut usage = e.partial_usage();
309 usage += sub_tokens;
310 Err(e.with_partial_usage(usage))
311 }
312 }
313 }
314}
315
316struct DelegateTaskTool {
324 shared_provider: Arc<BoxedProvider>,
325 sub_agents: Vec<SubAgentDef>,
326 max_turns: usize,
327 max_tokens: u32,
328 permission_rules: super::permission::PermissionRuleset,
330 accumulated_tokens: Arc<Mutex<TokenUsage>>,
332 shared_memory: Option<Arc<dyn Memory>>,
334 memory_namespace_prefix: Option<String>,
337 blackboard: Option<Arc<dyn Blackboard>>,
339 knowledge_base: Option<Arc<dyn KnowledgeBase>>,
341 cached_definition: ToolDefinition,
344 on_event: Option<Arc<OnEvent>>,
346 lsp_manager: Option<Arc<crate::lsp::LspManager>>,
348 observability_mode: super::observability::ObservabilityMode,
350 allow_shared_write: bool,
352 tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
354 guardrails: Vec<Arc<dyn Guardrail>>,
359 on_approval: Option<Arc<crate::llm::OnApproval>>,
364 learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
368 fanout_refused: std::sync::Mutex<Option<u64>>,
373}
374
375const SAME_AGENT_FANOUT_NUDGE_AT: usize = 3;
382
383fn same_agent_fanout(tasks: &[DelegatedTask], threshold: usize) -> Option<(String, usize)> {
389 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
390 for t in tasks {
391 *counts.entry(t.agent.as_str()).or_insert(0) += 1;
392 }
393 tasks
394 .iter()
395 .map(|t| t.agent.as_str())
396 .find(|name| counts[name] >= threshold)
397 .map(|name| (name.to_string(), counts[name]))
398}
399
400fn batch_fingerprint(tasks: &[DelegatedTask]) -> u64 {
405 use std::hash::{Hash, Hasher};
406 let mut h = std::collections::hash_map::DefaultHasher::new();
407 for t in tasks {
408 t.agent.hash(&mut h);
409 t.task.hash(&mut h);
410 }
411 h.finish()
412}
413
414fn fanout_guidance(agent: &str, count: usize) -> String {
420 format!(
421 "You delegated {count} parallel tasks to the SAME agent '{agent}'. delegate_task \
422 runs them as ISOLATED parallel agents — none can see another's files or output. \
423 If these tasks are INTERDEPENDENT (they build one artifact, or one needs another's \
424 result — e.g. a manifest needs the directory to exist, modules need the crate), \
425 isolated parallel agents will race and thrash. Do ONE of these instead: (1) build \
426 this set YOURSELF, in order, with your own write/edit/bash tools; or (2) send it as \
427 a SINGLE delegate_task to ONE agent, describing every step in order, so one agent \
428 does them sequentially in one workspace. If these {count} tasks are genuinely \
429 INDEPENDENT (e.g. each reads a different file), re-issue the SAME call unchanged and \
430 it will run."
431 )
432}
433
434impl DelegateTaskTool {
435 async fn delegate(&self, tasks: Vec<DelegatedTask>) -> Result<String, Error> {
436 if tasks.is_empty() {
437 return Err(Error::Agent(
438 "delegate_task requires at least one task".into(),
439 ));
440 }
441 let task_count = tasks.len();
442 let agent_names: Vec<String> = tasks.iter().map(|t| t.agent.clone()).collect();
443 let _delegate_span = info_span!(
444 "heartbit.orchestrator.delegate",
445 agent_count = task_count,
446 agents = ?agent_names,
447 );
448
449 if let Some(ref cb) = self.on_event {
450 cb(AgentEvent::SubAgentsDispatched {
451 agent: "orchestrator".into(),
452 agents: agent_names.clone(),
453 });
454 }
455
456 let mut join_set = tokio::task::JoinSet::new();
457
458 for (idx, task) in tasks.into_iter().enumerate() {
459 let agent_def = match self.sub_agents.iter().find(|a| a.name == task.agent) {
460 Some(def) => def.clone(),
461 None => {
462 let agent_name = task.agent.clone();
464 join_set.spawn(async move {
465 (
466 idx,
467 SubAgentResult {
468 agent: agent_name.clone(),
469 result: format!("Error: unknown agent '{agent_name}'"),
470 tokens_used: TokenUsage::default(),
471 success: false,
472 },
473 )
474 });
475 continue;
476 }
477 };
478
479 let provider = agent_def
480 .provider_override
481 .clone()
482 .unwrap_or_else(|| self.shared_provider.clone());
483 let max_turns = agent_def.max_turns.unwrap_or(self.max_turns);
484 let max_tokens = agent_def.max_tokens.unwrap_or(self.max_tokens);
485 let shared_memory = self.shared_memory.clone();
486 let ns_prefix = self.memory_namespace_prefix.clone();
487 let blackboard = self.blackboard.clone();
488 let knowledge_base = self.knowledge_base.clone();
489 let on_event = self.on_event.clone();
490 let lsp_manager = self.lsp_manager.clone();
491 let permission_rules = self.permission_rules.clone();
492 let observability_mode = self.observability_mode;
493 let allow_shared_write = self.allow_shared_write;
494 let tenant_tracker = self.tenant_tracker.clone();
495 let orchestrator_guardrails = self.guardrails.clone();
501 let on_approval = self.on_approval.clone();
504 let learned_permissions = self.learned_permissions.clone();
505
506 info!(agent = %agent_def.name, task = %task.task, "spawning sub-agent");
507
508 join_set.spawn(async move {
509 let mut builder = AgentRunner::builder(provider)
510 .name(&agent_def.name)
511 .system_prompt(&agent_def.system_prompt)
512 .tools(agent_def.tools)
513 .max_turns(max_turns)
514 .max_tokens(max_tokens);
515
516 if let Some(strategy) = agent_def.context_strategy {
517 builder = builder.context_strategy(strategy);
518 }
519 if let Some(threshold) = agent_def.summarize_threshold {
520 builder = builder.summarize_threshold(threshold);
521 }
522 if let Some(timeout) = agent_def.tool_timeout {
523 builder = builder.tool_timeout(timeout);
524 }
525 if let Some(max) = agent_def.max_tool_output_bytes {
526 builder = builder.max_tool_output_bytes(max);
527 }
528 if let Some(schema) = agent_def.response_schema {
529 builder = builder.structured_schema(schema);
530 }
531 let mut combined_guardrails = orchestrator_guardrails;
535 combined_guardrails.extend(agent_def.guardrails);
536 if !combined_guardrails.is_empty() {
537 builder = builder.guardrails(combined_guardrails);
538 }
539 if let Some(timeout) = agent_def.run_timeout {
540 builder = builder.run_timeout(timeout);
541 }
542 if let Some(effort) = agent_def.reasoning_effort {
543 builder = builder.reasoning_effort(effort);
544 }
545 if let Some(true) = agent_def.enable_reflection {
546 builder = builder.enable_reflection(true);
547 }
548 if let Some(threshold) = agent_def.tool_output_compression_threshold {
549 builder = builder.tool_output_compression_threshold(threshold);
550 }
551 if let Some(max) = agent_def.max_tools_per_turn {
552 builder = builder.max_tools_per_turn(max);
553 }
554 if let Some(profile) = agent_def.tool_profile {
555 builder = builder.tool_profile(profile);
556 }
557 if let Some(max) = agent_def.max_identical_tool_calls {
558 builder = builder.max_identical_tool_calls(max);
559 }
560 if let Some(max) = agent_def.max_fuzzy_identical_tool_calls {
561 builder = builder.max_fuzzy_identical_tool_calls(max);
562 }
563 if let Some(cap) = agent_def.max_tool_calls_per_turn {
564 builder = builder.max_tool_calls_per_turn(cap);
565 }
566 if let Some(ref config) = agent_def.session_prune_config {
567 builder = builder.session_prune_config(config.clone());
568 }
569 if let Some(true) = agent_def.enable_recursive_summarization {
570 builder = builder.enable_recursive_summarization(true);
571 }
572 if let Some(threshold) = agent_def.reflection_threshold {
573 builder = builder.reflection_threshold(threshold);
574 }
575 if let Some(true) = agent_def.consolidate_on_exit {
576 builder = builder.consolidate_on_exit(true);
577 }
578 if let Some(ref ws) = agent_def.workspace {
579 builder = builder.workspace(ws.clone());
580 }
581 if let Some(max) = agent_def.max_total_tokens {
582 builder = builder.max_total_tokens(max);
583 }
584 if let Some(trail) = agent_def.audit_trail.clone() {
585 builder = builder.audit_trail(trail);
586 }
587 if let Some(uid) = &agent_def.audit_user_id
588 && let Some(tid) = &agent_def.audit_tenant_id
589 {
590 builder = builder.audit_user_context(uid.clone(), tid.clone());
591 }
592 if !agent_def.audit_delegation_chain.is_empty() {
593 builder =
594 builder.audit_delegation_chain(agent_def.audit_delegation_chain.clone());
595 }
596
597 if !permission_rules.is_empty() {
599 builder = builder.permission_rules(permission_rules);
600 }
601
602 if let Some(ref cb) = on_approval {
607 builder = builder.on_approval(cb.clone());
608 }
609 if let Some(ref learned) = learned_permissions {
611 builder = builder.learned_permissions(learned.clone());
612 }
613
614 builder = builder.observability_mode(observability_mode);
616
617 if let Some(ref tracker) = tenant_tracker {
620 builder = builder.tenant_tracker(tracker.clone());
621 }
622
623 if let Some(ref lsp) = lsp_manager {
625 builder = builder.lsp_manager(lsp.clone());
626 }
627
628 if let Some(ref on_event) = on_event {
630 builder = builder.on_event(on_event.clone());
631 }
632 if let Some(store) = agent_def.context.todo_store.clone() {
643 builder = builder.todo_store(store);
644 }
645 if agent_def.context.replan_on_verify_fail {
646 builder = builder.replan_on_verify_fail(true);
647 }
648 if let Some(window) = agent_def.context.context_window_tokens {
649 builder = builder.context_window_tokens(window);
650 }
651 if let Some(store) = agent_def.context.context_recall_store.clone() {
652 builder = builder.context_recall_store(store);
653 }
654
655 if let Some(ref memory) = shared_memory {
657 let agent_ns = match &ns_prefix {
658 Some(prefix) => format!("{prefix}:{}", agent_def.name),
659 None => agent_def.name.clone(),
660 };
661 let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
662 memory.clone(),
663 &agent_ns,
664 ));
665 builder = builder.memory(ns);
666 let mem_scope = crate::auth::TenantScope::from_audit_fields(
667 agent_def.audit_tenant_id.as_deref(),
668 agent_def.audit_user_id.as_deref(),
669 );
670 builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
671 memory.clone(),
672 &agent_ns,
673 mem_scope,
674 allow_shared_write,
675 ));
676 }
677
678 if let Some(ref bb) = blackboard {
683 let bb_audit = agent_def.audit_trail.clone().map(|trail| {
684 crate::agent::blackboard_tools::BlackboardAudit {
685 trail,
686 user_id: agent_def.audit_user_id.clone(),
687 tenant_id: agent_def.audit_tenant_id.clone(),
688 }
689 });
690 builder =
691 builder.tools(blackboard_tools(bb.clone(), &agent_def.name, bb_audit));
692 }
693
694 if let Some(ref kb) = knowledge_base {
696 builder = builder.knowledge(kb.clone());
697 }
698
699 let runner = match builder.build() {
700 Ok(r) => r,
701 Err(e) => {
702 return (
703 idx,
704 SubAgentResult {
705 agent: agent_def.name,
706 result: format!("Error building agent: {e}"),
707 tokens_used: TokenUsage::default(),
708 success: false,
709 },
710 );
711 }
712 };
713
714 let result = match runner.execute(&task.task).await {
715 Ok(output) => {
716 if let Some(ref bb) = blackboard {
718 let key = format!("agent:{}", agent_def.name);
719 if let Err(e) = bb
720 .write(&key, serde_json::Value::String(output.result.clone()))
721 .await
722 {
723 tracing::warn!(
724 agent = %agent_def.name,
725 error = %e,
726 "failed to write result to blackboard"
727 );
728 }
729 }
730 SubAgentResult {
731 agent: agent_def.name,
732 result: output.result,
733 tokens_used: output.tokens_used,
734 success: true,
735 }
736 }
737 Err(e) => SubAgentResult {
738 agent: agent_def.name,
739 result: format!("Error: {e}"),
740 tokens_used: e.partial_usage(),
741 success: false,
742 },
743 };
744
745 (idx, result)
746 });
747 }
748
749 let mut results: Vec<Option<(usize, SubAgentResult)>> = vec![None; task_count];
750 while let Some(result) = join_set.join_next().await {
751 match result {
752 Ok((idx, sub_result)) => {
753 results[idx] = Some((idx, sub_result));
754 }
755 Err(e) => {
756 tracing::error!(error = %e, "sub-agent task panicked");
757 }
758 }
759 }
760
761 let mut results: Vec<(usize, SubAgentResult)> = results
763 .into_iter()
764 .enumerate()
765 .map(|(idx, r)| {
766 r.unwrap_or_else(|| {
767 (
768 idx,
769 SubAgentResult {
770 agent: agent_names[idx].clone(),
771 result: "Error: sub-agent task panicked".into(),
772 tokens_used: TokenUsage::default(),
773 success: false,
774 },
775 )
776 })
777 })
778 .collect();
779 results.sort_by_key(|(idx, _)| *idx);
780
781 {
783 let mut acc = self.accumulated_tokens.lock().expect("token lock poisoned");
784 for (_, r) in &results {
785 *acc += r.tokens_used;
786 }
787 }
788
789 if let Some(ref cb) = self.on_event {
791 for (_, r) in &results {
792 cb(AgentEvent::SubAgentCompleted {
793 agent: r.agent.clone(),
794 success: r.success,
795 usage: r.tokens_used,
796 });
797 }
798 }
799
800 let formatted = results
801 .iter()
802 .map(|(_, r)| format!("=== Agent: {} ===\n{}", r.agent, r.result))
803 .collect::<Vec<_>>()
804 .join("\n\n");
805
806 Ok(formatted)
807 }
808}
809
810impl Tool for DelegateTaskTool {
811 fn definition(&self) -> ToolDefinition {
812 self.cached_definition.clone()
813 }
814
815 fn execute(
816 &self,
817 _ctx: &crate::ExecutionContext,
818 input: serde_json::Value,
819 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
820 Box::pin(async move {
821 let delegate_input: DelegateInput = serde_json::from_value(input)
822 .map_err(|e| Error::Agent(format!("Invalid delegate_task input: {e}")))?;
823
824 if let Some((agent, count)) =
832 same_agent_fanout(&delegate_input.tasks, SAME_AGENT_FANOUT_NUDGE_AT)
833 {
834 let fp = batch_fingerprint(&delegate_input.tasks);
835 let is_retry = {
836 let mut last = self
837 .fanout_refused
838 .lock()
839 .expect("fanout_refused lock poisoned");
840 if *last == Some(fp) {
841 *last = None; true
843 } else {
844 *last = Some(fp);
845 false
846 }
847 };
848 if is_retry {
849 if let Some(cb) = &self.on_event {
850 cb(AgentEvent::GateFired {
851 agent: "orchestrator".into(),
852 gate: "same_agent_fanout_dispatched".into(),
853 reason: format!(
854 "{count} parallel tasks to '{agent}' dispatched after nudge \
855 (retry-through)"
856 ),
857 });
858 }
859 } else {
860 if let Some(cb) = &self.on_event {
861 cb(AgentEvent::GateFired {
862 agent: "orchestrator".into(),
863 gate: "same_agent_fanout".into(),
864 reason: format!(
865 "{count} parallel tasks delegated to the same agent '{agent}'"
866 ),
867 });
868 }
869 return Ok(ToolOutput::error(fanout_guidance(&agent, count)));
870 }
871 }
872
873 let result = self.delegate(delegate_input.tasks).await?;
874 Ok(ToolOutput::success(result))
875 })
876 }
877}
878
879#[derive(Deserialize)]
880struct DelegateInput {
881 tasks: Vec<DelegatedTask>,
882}
883
884struct FormSquadTool {
894 shared_provider: Arc<BoxedProvider>,
895 agent_pool: Vec<SubAgentDef>,
896 default_max_turns: usize,
897 default_max_tokens: u32,
898 permission_rules: super::permission::PermissionRuleset,
900 accumulated_tokens: Arc<Mutex<TokenUsage>>,
901 shared_memory: Option<Arc<dyn Memory>>,
902 memory_namespace_prefix: Option<String>,
903 blackboard: Option<Arc<dyn Blackboard>>,
905 knowledge_base: Option<Arc<dyn KnowledgeBase>>,
906 on_event: Option<Arc<OnEvent>>,
907 lsp_manager: Option<Arc<crate::lsp::LspManager>>,
909 cached_definition: ToolDefinition,
910 observability_mode: super::observability::ObservabilityMode,
912 allow_shared_write: bool,
914 tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
916 guardrails: Vec<Arc<dyn Guardrail>>,
920 on_approval: Option<Arc<crate::llm::OnApproval>>,
924 learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
927}
928
929impl Tool for FormSquadTool {
930 fn definition(&self) -> ToolDefinition {
931 self.cached_definition.clone()
932 }
933
934 fn execute(
935 &self,
936 _ctx: &crate::ExecutionContext,
937 input: serde_json::Value,
938 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
939 Box::pin(async move {
940 let delegate_input: DelegateInput = serde_json::from_value(input)
941 .map_err(|e| Error::Agent(format!("Invalid form_squad input: {e}")))?;
942
943 let tasks = delegate_input.tasks;
944
945 if tasks.len() < 2 {
947 return Ok(ToolOutput::error(
948 "form_squad requires at least 2 tasks. Use delegate_task for single-agent tasks."
949 .to_string(),
950 ));
951 }
952
953 {
957 let mut seen = std::collections::HashSet::new();
958 for t in &tasks {
959 if !seen.insert(&t.agent) {
960 return Ok(ToolOutput::error(format!(
961 "Duplicate agent name in squad: '{}'. Each form_squad \
962 task needs a DISTINCT agent (available: {}). To run \
963 several tasks on the SAME agent in parallel, use \
964 delegate_task with multiple tasks instead.",
965 t.agent,
966 self.agent_pool
967 .iter()
968 .map(|a| a.name.as_str())
969 .collect::<Vec<_>>()
970 .join(", ")
971 )));
972 }
973 }
974 }
975
976 for t in &tasks {
978 if !self.agent_pool.iter().any(|a| a.name == t.agent) {
979 return Ok(ToolOutput::error(format!(
980 "Unknown agent '{}'. Available agents: {}",
981 t.agent,
982 self.agent_pool
983 .iter()
984 .map(|a| a.name.as_str())
985 .collect::<Vec<_>>()
986 .join(", ")
987 )));
988 }
989 }
990
991 let task_count = tasks.len();
992 let agent_names: Vec<String> = tasks.iter().map(|t| t.agent.clone()).collect();
993
994 let private_bb: Arc<dyn Blackboard> = Arc::new(InMemoryBlackboard::new());
996
997 let _squad_span = info_span!(
998 "heartbit.orchestrator.squad",
999 agent_count = task_count,
1000 agents = ?agent_names,
1001 );
1002
1003 if let Some(ref cb) = self.on_event {
1004 cb(AgentEvent::SubAgentsDispatched {
1005 agent: "squad-leader".into(),
1006 agents: agent_names.clone(),
1007 });
1008 }
1009
1010 let mut join_set = tokio::task::JoinSet::new();
1012
1013 for (idx, task) in tasks.into_iter().enumerate() {
1014 let agent_def = match self.agent_pool.iter().find(|a| a.name == task.agent) {
1016 Some(def) => def.clone(),
1017 None => {
1018 return Ok(ToolOutput::error(format!(
1019 "Internal error: agent '{}' not found after validation",
1020 task.agent
1021 )));
1022 }
1023 };
1024
1025 let provider = agent_def
1026 .provider_override
1027 .clone()
1028 .unwrap_or_else(|| self.shared_provider.clone());
1029 let max_turns = agent_def.max_turns.unwrap_or(self.default_max_turns);
1030 let max_tokens = agent_def.max_tokens.unwrap_or(self.default_max_tokens);
1031 let shared_memory = self.shared_memory.clone();
1032 let ns_prefix = self.memory_namespace_prefix.clone();
1033 let bb = private_bb.clone();
1034 let knowledge_base = self.knowledge_base.clone();
1035 let on_event = self.on_event.clone();
1036 let lsp_manager = self.lsp_manager.clone();
1037 let permission_rules = self.permission_rules.clone();
1038 let observability_mode = self.observability_mode;
1039 let allow_shared_write = self.allow_shared_write;
1040 let tenant_tracker = self.tenant_tracker.clone();
1041 let squad_guardrails = self.guardrails.clone();
1043 let on_approval = self.on_approval.clone();
1046 let learned_permissions = self.learned_permissions.clone();
1047
1048 info!(agent = %agent_def.name, task = %task.task, "spawning squad member");
1049
1050 join_set.spawn(async move {
1051 let mut builder = AgentRunner::builder(provider)
1052 .name(&agent_def.name)
1053 .system_prompt(&agent_def.system_prompt)
1054 .tools(agent_def.tools)
1055 .max_turns(max_turns)
1056 .max_tokens(max_tokens);
1057
1058 if let Some(strategy) = agent_def.context_strategy {
1059 builder = builder.context_strategy(strategy);
1060 }
1061 if let Some(threshold) = agent_def.summarize_threshold {
1062 builder = builder.summarize_threshold(threshold);
1063 }
1064 if let Some(timeout) = agent_def.tool_timeout {
1065 builder = builder.tool_timeout(timeout);
1066 }
1067 if let Some(max) = agent_def.max_tool_output_bytes {
1068 builder = builder.max_tool_output_bytes(max);
1069 }
1070 if let Some(schema) = agent_def.response_schema {
1071 builder = builder.structured_schema(schema);
1072 }
1073 let mut combined_guardrails = squad_guardrails;
1076 combined_guardrails.extend(agent_def.guardrails);
1077 if !combined_guardrails.is_empty() {
1078 builder = builder.guardrails(combined_guardrails);
1079 }
1080 if let Some(timeout) = agent_def.run_timeout {
1081 builder = builder.run_timeout(timeout);
1082 }
1083 if let Some(effort) = agent_def.reasoning_effort {
1084 builder = builder.reasoning_effort(effort);
1085 }
1086 if let Some(true) = agent_def.enable_reflection {
1087 builder = builder.enable_reflection(true);
1088 }
1089 if let Some(threshold) = agent_def.tool_output_compression_threshold {
1090 builder = builder.tool_output_compression_threshold(threshold);
1091 }
1092 if let Some(max) = agent_def.max_tools_per_turn {
1093 builder = builder.max_tools_per_turn(max);
1094 }
1095 if let Some(profile) = agent_def.tool_profile {
1096 builder = builder.tool_profile(profile);
1097 }
1098 if let Some(max) = agent_def.max_identical_tool_calls {
1099 builder = builder.max_identical_tool_calls(max);
1100 }
1101 if let Some(max) = agent_def.max_fuzzy_identical_tool_calls {
1102 builder = builder.max_fuzzy_identical_tool_calls(max);
1103 }
1104 if let Some(cap) = agent_def.max_tool_calls_per_turn {
1105 builder = builder.max_tool_calls_per_turn(cap);
1106 }
1107 if let Some(ref config) = agent_def.session_prune_config {
1108 builder = builder.session_prune_config(config.clone());
1109 }
1110 if let Some(true) = agent_def.enable_recursive_summarization {
1111 builder = builder.enable_recursive_summarization(true);
1112 }
1113 if let Some(threshold) = agent_def.reflection_threshold {
1114 builder = builder.reflection_threshold(threshold);
1115 }
1116 if let Some(true) = agent_def.consolidate_on_exit {
1117 builder = builder.consolidate_on_exit(true);
1118 }
1119 if let Some(ref ws) = agent_def.workspace {
1120 builder = builder.workspace(ws.clone());
1121 }
1122 if let Some(max) = agent_def.max_total_tokens {
1123 builder = builder.max_total_tokens(max);
1124 }
1125 if let Some(trail) = agent_def.audit_trail.clone() {
1126 builder = builder.audit_trail(trail);
1127 }
1128 if let Some(uid) = &agent_def.audit_user_id
1129 && let Some(tid) = &agent_def.audit_tenant_id
1130 {
1131 builder = builder.audit_user_context(uid.clone(), tid.clone());
1132 }
1133 if !agent_def.audit_delegation_chain.is_empty() {
1134 builder = builder
1135 .audit_delegation_chain(agent_def.audit_delegation_chain.clone());
1136 }
1137
1138 if !permission_rules.is_empty() {
1140 builder = builder.permission_rules(permission_rules);
1141 }
1142
1143 if let Some(ref cb) = on_approval {
1147 builder = builder.on_approval(cb.clone());
1148 }
1149 if let Some(ref learned) = learned_permissions {
1151 builder = builder.learned_permissions(learned.clone());
1152 }
1153
1154 builder = builder.observability_mode(observability_mode);
1156
1157 if let Some(ref tracker) = tenant_tracker {
1160 builder = builder.tenant_tracker(tracker.clone());
1161 }
1162
1163 if let Some(ref lsp) = lsp_manager {
1165 builder = builder.lsp_manager(lsp.clone());
1166 }
1167
1168 if let Some(ref on_event) = on_event {
1170 builder = builder.on_event(on_event.clone());
1171 }
1172 if let Some(store) = agent_def.context.todo_store.clone() {
1178 builder = builder.todo_store(store);
1179 }
1180 if agent_def.context.replan_on_verify_fail {
1181 builder = builder.replan_on_verify_fail(true);
1182 }
1183 if let Some(window) = agent_def.context.context_window_tokens {
1184 builder = builder.context_window_tokens(window);
1185 }
1186 if let Some(store) = agent_def.context.context_recall_store.clone() {
1187 builder = builder.context_recall_store(store);
1188 }
1189
1190 if let Some(ref memory) = shared_memory {
1192 let agent_ns = match &ns_prefix {
1193 Some(prefix) => format!("{prefix}:{}", agent_def.name),
1194 None => agent_def.name.clone(),
1195 };
1196 let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
1197 memory.clone(),
1198 &agent_ns,
1199 ));
1200 builder = builder.memory(ns);
1201 let mem_scope = crate::auth::TenantScope::from_audit_fields(
1202 agent_def.audit_tenant_id.as_deref(),
1203 agent_def.audit_user_id.as_deref(),
1204 );
1205 builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
1206 memory.clone(),
1207 &agent_ns,
1208 mem_scope,
1209 allow_shared_write,
1210 ));
1211 }
1212
1213 let bb_audit = agent_def.audit_trail.clone().map(|trail| {
1217 crate::agent::blackboard_tools::BlackboardAudit {
1218 trail,
1219 user_id: agent_def.audit_user_id.clone(),
1220 tenant_id: agent_def.audit_tenant_id.clone(),
1221 }
1222 });
1223 builder =
1224 builder.tools(blackboard_tools(bb.clone(), &agent_def.name, bb_audit));
1225
1226 if let Some(ref kb) = knowledge_base {
1228 builder = builder.knowledge(kb.clone());
1229 }
1230
1231 let runner = match builder.build() {
1232 Ok(r) => r,
1233 Err(e) => {
1234 return (
1235 idx,
1236 SubAgentResult {
1237 agent: agent_def.name,
1238 result: format!("Error building agent: {e}"),
1239 tokens_used: TokenUsage::default(),
1240 success: false,
1241 },
1242 );
1243 }
1244 };
1245
1246 let result = match runner.execute(&task.task).await {
1247 Ok(output) => {
1248 let key = format!("agent:{}", agent_def.name);
1250 if let Err(e) = bb
1251 .write(&key, serde_json::Value::String(output.result.clone()))
1252 .await
1253 {
1254 tracing::warn!(
1255 agent = %agent_def.name,
1256 error = %e,
1257 "failed to write result to private blackboard"
1258 );
1259 }
1260 SubAgentResult {
1261 agent: agent_def.name,
1262 result: output.result,
1263 tokens_used: output.tokens_used,
1264 success: true,
1265 }
1266 }
1267 Err(e) => SubAgentResult {
1268 agent: agent_def.name,
1269 result: format!("Error: {e}"),
1270 tokens_used: e.partial_usage(),
1271 success: false,
1272 },
1273 };
1274
1275 (idx, result)
1276 });
1277 }
1278
1279 let mut results: Vec<Option<(usize, SubAgentResult)>> = vec![None; task_count];
1280 while let Some(result) = join_set.join_next().await {
1281 match result {
1282 Ok((idx, sub_result)) => {
1283 results[idx] = Some((idx, sub_result));
1284 }
1285 Err(e) => {
1286 tracing::error!(error = %e, "squad member task panicked");
1287 }
1288 }
1289 }
1290
1291 let mut results: Vec<(usize, SubAgentResult)> = results
1293 .into_iter()
1294 .enumerate()
1295 .map(|(idx, r)| {
1296 r.unwrap_or_else(|| {
1297 (
1298 idx,
1299 SubAgentResult {
1300 agent: agent_names[idx].clone(),
1301 result: "Error: squad member task panicked".into(),
1302 tokens_used: TokenUsage::default(),
1303 success: false,
1304 },
1305 )
1306 })
1307 })
1308 .collect();
1309 results.sort_by_key(|(idx, _)| *idx);
1310
1311 let squad_label = format!("squad[{}]", agent_names.join(","));
1312 let bb_key = format!("squad:{}", agent_names.join("+"));
1313
1314 let mut total_tokens = TokenUsage::default();
1316 {
1317 let mut acc = self.accumulated_tokens.lock().expect("token lock poisoned");
1318 for (_, r) in &results {
1319 *acc += r.tokens_used;
1320 total_tokens += r.tokens_used;
1321 }
1322 }
1323
1324 if let Some(ref cb) = self.on_event {
1326 for (_, r) in &results {
1327 cb(AgentEvent::SubAgentCompleted {
1328 agent: r.agent.clone(),
1329 success: r.success,
1330 usage: r.tokens_used,
1331 });
1332 }
1333 }
1334
1335 let all_success = results.iter().all(|(_, r)| r.success);
1336
1337 let formatted = results
1338 .iter()
1339 .map(|(_, r)| format!("=== Agent: {} ===\n{}", r.agent, r.result))
1340 .collect::<Vec<_>>()
1341 .join("\n\n");
1342
1343 if let Some(ref cb) = self.on_event {
1345 cb(AgentEvent::SubAgentCompleted {
1346 agent: squad_label,
1347 success: all_success,
1348 usage: total_tokens,
1349 });
1350 }
1351
1352 if let Some(ref bb) = self.blackboard
1354 && let Err(e) = bb
1355 .write(&bb_key, serde_json::Value::String(formatted.clone()))
1356 .await
1357 {
1358 tracing::warn!(
1359 key = %bb_key,
1360 error = %e,
1361 "failed to write squad result to outer blackboard"
1362 );
1363 }
1364
1365 Ok(ToolOutput::success(formatted))
1366 })
1367 }
1368}
1369
1370struct SpawnAgentTool {
1376 shared_provider: Arc<BoxedProvider>,
1377 spawn_config: crate::types::SpawnConfig,
1378 tool_pool: std::collections::HashMap<String, Arc<dyn Tool>>,
1380 spawn_count: Arc<std::sync::atomic::AtomicU32>,
1382 spawned_names: Arc<Mutex<std::collections::HashSet<String>>>,
1384 accumulated_tokens: Arc<Mutex<TokenUsage>>,
1386 permission_rules: super::permission::PermissionRuleset,
1387 shared_memory: Option<Arc<dyn Memory>>,
1388 memory_namespace_prefix: Option<String>,
1389 on_event: Option<Arc<OnEvent>>,
1390 lsp_manager: Option<Arc<crate::lsp::LspManager>>,
1391 observability_mode: super::observability::ObservabilityMode,
1392 workspace: Option<std::path::PathBuf>,
1393 guardrails: Vec<Arc<dyn Guardrail>>,
1394 audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
1395 audit_user_id: Option<String>,
1396 audit_tenant_id: Option<String>,
1397 audit_delegation_chain: Vec<String>,
1398 cached_definition: ToolDefinition,
1399 tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
1401 on_approval: Option<Arc<crate::llm::OnApproval>>,
1405 learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
1408}
1409
1410#[derive(Deserialize)]
1411struct SpawnAgentInput {
1412 name: String,
1413 system_prompt: String,
1414 #[serde(default)]
1415 tools: Vec<String>,
1416 task: String,
1417}
1418
1419const SPAWN_MAX_PROMPT_BYTES: usize = 32 * 1024;
1421
1422impl SpawnAgentTool {
1423 fn build_definition(config: &crate::types::SpawnConfig) -> ToolDefinition {
1424 let allowlist = if config.tool_allowlist.is_empty() {
1425 "(none — reasoning-only agents)".to_string()
1426 } else {
1427 config.tool_allowlist.join(", ")
1428 };
1429 ToolDefinition {
1430 name: "spawn_agent".into(),
1431 description: format!(
1432 "Create a new specialist agent at runtime when no pre-configured agent fits the task. \
1433 The spawned agent runs with the given system prompt and tool subset, then returns its result.\n\n\
1434 Available tools for spawned agents: [{allowlist}]. Budget: {} agents max per run.",
1435 config.max_spawned_agents
1436 ),
1437 input_schema: json!({
1438 "type": "object",
1439 "required": ["name", "system_prompt", "task"],
1440 "properties": {
1441 "name": {
1442 "type": "string",
1443 "description": "Lowercase identifier for the agent (a-z, 0-9, underscores). Must start with a letter. E.g. 'tax_specialist', 'csv_analyzer'."
1444 },
1445 "system_prompt": {
1446 "type": "string",
1447 "description": "The agent's role and behavior instructions. Be specific about expertise and constraints."
1448 },
1449 "tools": {
1450 "type": "array",
1451 "items": { "type": "string" },
1452 "description": format!("Subset of available tools: [{allowlist}]. Empty array creates a reasoning-only agent.")
1453 },
1454 "task": {
1455 "type": "string",
1456 "description": "The specific task for this agent to accomplish."
1457 }
1458 },
1459 "additionalProperties": false
1460 }),
1461 }
1462 }
1463
1464 async fn spawn(&self, input: SpawnAgentInput) -> Result<ToolOutput, Error> {
1465 use std::sync::atomic::{AtomicU32, Ordering};
1466
1467 struct SlotGuard<'a> {
1475 counter: &'a AtomicU32,
1476 committed: bool,
1477 }
1478 impl Drop for SlotGuard<'_> {
1479 fn drop(&mut self) {
1480 if !self.committed {
1481 self.counter.fetch_sub(1, Ordering::AcqRel);
1482 }
1483 }
1484 }
1485
1486 let reserved = self.spawn_count.fetch_add(1, Ordering::AcqRel);
1487 let mut slot = SlotGuard {
1488 counter: &self.spawn_count,
1489 committed: false,
1490 };
1491 if reserved >= self.spawn_config.max_spawned_agents {
1492 return Ok(ToolOutput::error(format!(
1493 "Spawn limit reached: {reserved}/{} agents already spawned this run.",
1494 self.spawn_config.max_spawned_agents
1495 )));
1496 }
1497
1498 let name_re =
1500 regex::Regex::new(r"^[a-z][a-z0-9_]{0,63}$").expect("spawn agent name regex is valid");
1501 if !name_re.is_match(&input.name) {
1502 return Ok(ToolOutput::error(format!(
1503 "Invalid agent name '{}'. Must match ^[a-z][a-z0-9_]{{0,63}}$ \
1504 (lowercase, starts with letter, alphanumeric + underscores, max 64 chars).",
1505 input.name
1506 )));
1507 }
1508
1509 {
1511 let mut names = self.spawned_names.lock().expect("spawned names lock");
1512 if !names.insert(input.name.clone()) {
1513 return Ok(ToolOutput::error(format!(
1514 "Agent name '{}' already used in this run. Choose a different name.",
1515 input.name
1516 )));
1517 }
1518 }
1519
1520 for tool_name in &input.tools {
1522 if !self.tool_pool.contains_key(tool_name) {
1523 let available: Vec<&str> = self.tool_pool.keys().map(|k| k.as_str()).collect();
1524 return Ok(ToolOutput::error(format!(
1525 "Tool '{}' not in allowlist. Available: [{}]",
1526 tool_name,
1527 available.join(", ")
1528 )));
1529 }
1530 }
1531
1532 {
1534 let acc = self.accumulated_tokens.lock().expect("token lock");
1535 let used = acc.total();
1536 if used >= self.spawn_config.max_total_tokens {
1537 return Ok(ToolOutput::error(format!(
1538 "Spawn token budget exhausted: {used}/{} tokens used across spawned agents.",
1539 self.spawn_config.max_total_tokens
1540 )));
1541 }
1542 }
1543
1544 if input.system_prompt.len() > SPAWN_MAX_PROMPT_BYTES {
1546 return Ok(ToolOutput::error(format!(
1547 "System prompt too long: {} bytes (max {SPAWN_MAX_PROMPT_BYTES}).",
1548 input.system_prompt.len()
1549 )));
1550 }
1551
1552 let spawned_name = format!("spawn:{}", input.name);
1553
1554 if let Some(ref cb) = self.on_event {
1556 cb(AgentEvent::AgentSpawned {
1557 agent: "orchestrator".into(),
1558 spawned_name: spawned_name.clone(),
1559 tools: input.tools.clone(),
1560 task: input.task.clone(),
1561 });
1562 }
1563
1564 let selected_tools: Vec<Arc<dyn Tool>> = input
1566 .tools
1567 .iter()
1568 .filter_map(|name| self.tool_pool.get(name).cloned())
1569 .collect();
1570
1571 let mut builder = AgentRunner::builder(self.shared_provider.clone())
1573 .name(&spawned_name)
1574 .system_prompt(&input.system_prompt)
1575 .tools(selected_tools)
1576 .max_turns(self.spawn_config.max_turns)
1577 .max_tokens(self.spawn_config.max_tokens)
1578 .observability_mode(self.observability_mode);
1579
1580 if !self.permission_rules.is_empty() {
1582 builder = builder.permission_rules(self.permission_rules.clone());
1583 }
1584 if !self.guardrails.is_empty() {
1585 builder = builder.guardrails(self.guardrails.clone());
1586 }
1587 if let Some(ref cb) = self.on_approval {
1590 builder = builder.on_approval(cb.clone());
1591 }
1592 if let Some(ref learned) = self.learned_permissions {
1594 builder = builder.learned_permissions(learned.clone());
1595 }
1596 if let Some(ref ws) = self.workspace {
1597 builder = builder.workspace(ws.clone());
1598 }
1599 if let Some(ref lsp) = self.lsp_manager {
1600 builder = builder.lsp_manager(lsp.clone());
1601 }
1602 if let Some(ref cb) = self.on_event {
1603 builder = builder.on_event(cb.clone());
1604 }
1605 if let Some(ref trail) = self.audit_trail {
1607 builder = builder.audit_trail(trail.clone());
1608 }
1609 if let (Some(uid), Some(tid)) = (&self.audit_user_id, &self.audit_tenant_id) {
1610 builder = builder.audit_user_context(uid.clone(), tid.clone());
1611 }
1612 if !self.audit_delegation_chain.is_empty() {
1613 let mut chain = self.audit_delegation_chain.clone();
1614 chain.push(spawned_name.clone());
1615 builder = builder.audit_delegation_chain(chain);
1616 }
1617
1618 if let Some(ref tracker) = self.tenant_tracker {
1621 builder = builder.tenant_tracker(tracker.clone());
1622 }
1623
1624 if let Some(ref memory) = self.shared_memory {
1626 let agent_ns = match &self.memory_namespace_prefix {
1627 Some(prefix) => format!("{prefix}:{spawned_name}"),
1628 None => spawned_name.clone(),
1629 };
1630 let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
1631 memory.clone(),
1632 &agent_ns,
1633 ));
1634 builder = builder.memory(ns);
1635 let mem_scope = crate::auth::TenantScope::from_audit_fields(
1636 self.audit_tenant_id.as_deref(),
1637 self.audit_user_id.as_deref(),
1638 );
1639 builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
1640 memory.clone(),
1641 &agent_ns,
1642 mem_scope,
1643 false, ));
1645 }
1646
1647 let runner = builder.build()?;
1648
1649 slot.committed = true;
1652
1653 info!(
1654 agent = %spawned_name,
1655 tools = ?input.tools,
1656 "spawning dynamic agent"
1657 );
1658
1659 match runner.execute(&input.task).await {
1661 Ok(output) => {
1662 {
1664 let mut acc = self.accumulated_tokens.lock().expect("token lock");
1665 *acc += output.tokens_used;
1666 }
1667 if let Some(ref cb) = self.on_event {
1668 cb(AgentEvent::SubAgentCompleted {
1669 agent: spawned_name.clone(),
1670 success: true,
1671 usage: output.tokens_used,
1672 });
1673 }
1674 Ok(ToolOutput::success(format!(
1675 "=== Spawned Agent: {} ===\n{}",
1676 spawned_name, output.result
1677 )))
1678 }
1679 Err(e) => {
1680 let partial = e.partial_usage();
1681 {
1682 let mut acc = self.accumulated_tokens.lock().expect("token lock");
1683 *acc += partial;
1684 }
1685 if let Some(ref cb) = self.on_event {
1686 cb(AgentEvent::SubAgentCompleted {
1687 agent: spawned_name.clone(),
1688 success: false,
1689 usage: partial,
1690 });
1691 }
1692 Ok(ToolOutput::error(format!(
1693 "Spawned agent '{spawned_name}' failed: {e}"
1694 )))
1695 }
1696 }
1697 }
1698}
1699
1700impl Tool for SpawnAgentTool {
1701 fn definition(&self) -> ToolDefinition {
1702 self.cached_definition.clone()
1703 }
1704
1705 fn execute(
1706 &self,
1707 _ctx: &crate::ExecutionContext,
1708 input: serde_json::Value,
1709 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
1710 Box::pin(async move {
1711 let spawn_input: SpawnAgentInput = serde_json::from_value(input)
1712 .map_err(|e| Error::Agent(format!("Invalid spawn_agent input: {e}")))?;
1713 self.spawn(spawn_input).await
1714 })
1715 }
1716}
1717
1718pub fn build_system_prompt(
1729 agents: &[(&str, &str, &[String])],
1730 squads_enabled: bool,
1731 dispatch_mode: DispatchMode,
1732) -> String {
1733 let agent_list: String = agents
1734 .iter()
1735 .map(|(name, desc, tools)| {
1736 if tools.is_empty() {
1737 format!("- **{name}**: {desc}\n Tools: (none)")
1738 } else {
1739 format!("- **{name}**: {desc}\n Tools: {}", tools.join(", "))
1740 }
1741 })
1742 .collect::<Vec<_>>()
1743 .join("\n");
1744
1745 let delegation_instructions = match (squads_enabled, dispatch_mode) {
1746 (_, DispatchMode::Sequential) => {
1747 "## Delegation Tool\n\
1748 Delegate to ONE agent at a time using **delegate_task**. Wait for the result \
1749 before deciding the next agent. Do NOT batch multiple agents in a single call."
1750 }
1751 (true, DispatchMode::Parallel) => {
1752 "## Delegation Tools\n\
1753 You have two delegation tools:\n\n\
1754 1. **delegate_task** — Run independent subtasks in parallel. Each agent works in \
1755 isolation and cannot see other agents' output. Use when subtasks are independent.\n\n\
1756 2. **form_squad** — Run subtasks in parallel with a shared blackboard. \
1757 Unlike delegate_task, agents can read each other's results via the blackboard. \
1758 Agents run concurrently — use when they benefit from shared state, not when \
1759 strict ordering is needed.\n\n\
1760 After receiving results, synthesize them into a coherent response."
1761 }
1762 (false, DispatchMode::Parallel) => {
1763 "## Delegation Tool\n\
1764 Use the **delegate_task** tool to assign work to sub-agents. You can assign \
1765 multiple tasks at once for parallel execution. Each agent works in isolation. \
1766 After receiving results, synthesize them into a coherent response."
1767 }
1768 };
1769
1770 let choose_tool_step = match (squads_enabled, dispatch_mode) {
1771 (_, DispatchMode::Sequential) => {
1772 "3. DELEGATE: Use delegate_task with ONE agent at a time. Wait for results before \
1773 delegating to the next agent."
1774 }
1775 (true, DispatchMode::Parallel) => {
1776 "3. CHOOSE TOOL: Select delegate_task for independent parallel work, or form_squad \
1777 when agents benefit from shared state via a blackboard."
1778 }
1779 (false, DispatchMode::Parallel) => {
1780 "3. DELEGATE: Use delegate_task to assign subtasks to the best-fit agents."
1781 }
1782 };
1783
1784 format!(
1785 "You are an orchestrator agent. Analyze incoming tasks and delegate work to \
1786 specialized sub-agents.\n\n\
1787 ## Decision Process\n\
1788 1. DECOMPOSE: Break the task into distinct subtasks. Identify which require different expertise.\n\
1789 2. MATCH: For each subtask, pick the best-fit agent based on their description and tools.\n\
1790 {choose_tool_step}\n\n\
1791 ## Important\n\
1792 - ALWAYS delegate to a sub-agent using your delegation tools. You do NOT have any \
1793 direct capabilities — sub-agents have the tools. Never respond to the user directly \
1794 without delegating first.\n\n\
1795 ## Effort Scaling\n\
1796 - If only ONE agent is relevant, delegate a single task. Do NOT force-split across agents.\n\
1797 - If the task is simple enough for one agent, use one agent.\n\
1798 - Only use multiple agents when the task genuinely has multiple distinct parts \
1799 needing different expertise.\n\n\
1800 ## Task Quality\n\
1801 - Each delegated task must be self-contained: include all context the agent needs.\n\
1802 - Be specific: \"Read /path/to/file and extract X\" not \"look at the project\".\n\
1803 - Avoid overlapping tasks — no two agents should do the same work.\n\n\
1804 ## Available Sub-Agents\n\
1805 Choose agents based on their description and available tools:\n\
1806 {agent_list}\n\n\
1807 {delegation_instructions}"
1808 )
1809}
1810
1811const ENTRY_DELEGATION_NUDGE_AFTER: u32 = 8;
1815
1816pub fn build_entry_agent_prompt(
1824 agents: &[(&str, &str, &[String])],
1825 squads_enabled: bool,
1826 dispatch_mode: DispatchMode,
1827 workflow_recipes: &[(&str, &str)],
1828) -> String {
1829 build_entry_agent_prompt_ext(
1830 agents,
1831 squads_enabled,
1832 dispatch_mode,
1833 workflow_recipes,
1834 EntryCaps::default(),
1835 )
1836}
1837
1838#[derive(Clone, Copy, Debug, Default)]
1842pub struct EntryCaps {
1843 pub ask_user: bool,
1845 pub set_goal: bool,
1847 pub set_scope: bool,
1849}
1850
1851pub fn build_entry_agent_prompt_ext(
1854 agents: &[(&str, &str, &[String])],
1855 squads_enabled: bool,
1856 dispatch_mode: DispatchMode,
1857 workflow_recipes: &[(&str, &str)],
1858 caps: EntryCaps,
1859) -> String {
1860 let agent_list: String = if agents.is_empty() {
1861 "(none configured — handle everything yourself with your own tools)".to_string()
1862 } else {
1863 agents
1864 .iter()
1865 .map(|(name, desc, tools)| {
1866 if tools.is_empty() {
1867 format!("- **{name}**: {desc}\n Tools: (none)")
1868 } else {
1869 format!("- **{name}**: {desc}\n Tools: {}", tools.join(", "))
1870 }
1871 })
1872 .collect::<Vec<_>>()
1873 .join("\n")
1874 };
1875
1876 let delegation_line = match (squads_enabled, dispatch_mode) {
1877 (_, DispatchMode::Sequential) => {
1878 "delegate ONE specialist at a time with **delegate_task** (wait for each result)."
1879 }
1880 (true, DispatchMode::Parallel) => {
1881 "delegate independent parts with **delegate_task** (isolated, parallel) or \
1882 **form_squad** (parallel + shared blackboard), then synthesize."
1883 }
1884 (false, DispatchMode::Parallel) => {
1885 "delegate independent parts with **delegate_task** (parallel), then synthesize."
1886 }
1887 };
1888
1889 let (workflow_decision_line, workflow_block) = if workflow_recipes.is_empty() {
1890 (String::new(), String::new())
1891 } else {
1892 let list = workflow_recipes
1893 .iter()
1894 .map(|(name, desc)| format!("- **{name}**: {desc}"))
1895 .collect::<Vec<_>>()
1896 .join("\n");
1897 (
1898 "\n - **A structured repeatable job matching a recipe**: use run_workflow \
1899 (see below)."
1900 .to_string(),
1901 format!(
1902 "\n\n## Workflow Recipes\n\
1903 For a structured, repeatable, multi-step job, launch a recipe with \
1904 **run_workflow** (give the recipe name + args). Available recipes:\n{list}"
1905 ),
1906 )
1907 };
1908
1909 let mut discipline = String::new();
1910 if caps.set_goal {
1911 discipline.push_str(
1912 "\n- **Substantive work gets a judged goal.** Right after planning, install the \
1913 acceptance criteria as your completion goal with `set_goal` (one observable \
1914 criterion per line — e.g. straight from the `intake` recipe's output). An \
1915 independent judge then gates your completion until the criteria are demonstrably \
1916 met — finish by SHOWING the evidence, not claiming it.\n",
1917 );
1918 }
1919 if caps.set_scope {
1920 discipline.push_str(
1921 "\n- **Declare your blast radius.** Before substantive edits, declare the \
1922 files/directories you intend to modify with `set_scope`; edits outside it are \
1923 denied. The file tools can ONLY write INSIDE the workspace, so 'a temporary \
1924 directory' means a fresh scratch SUBDIRECTORY inside the workspace (e.g. \
1925 ./scratch-<name>) — NOT /tmp or your home directory (those are rejected), and \
1926 NOT scattered among tracked files. Keep it gitignored so it stays disposable. \
1927 Widen the scope EXPLICITLY (set_scope again) when genuinely needed — never \
1928 drift into unrelated files.\n",
1929 );
1930 }
1931 let clarify_block = if caps.ask_user {
1932 "\n- **Underspecified request?** Before substantive work, if the next step depends on \
1933 user INTENT and guessing would cause rework (scope / risk / intent — e.g. which \
1934 behavior, destructive vs additive, which of two designs), ask the user with the \
1935 `question` tool: 1–4 batched questions, 2–4 concrete options each. Use the `question` \
1936 TOOL — not a free-text question in your reply — whenever the choices are enumerable \
1937 (the user answers with one keypress). Otherwise PROCEED and state your assumptions \
1938 explicitly (as todos when planning). Never ask about what you can discover yourself \
1939 from the code. NEVER re-ask anything the user ALREADY specified in the request (a \
1940 location like 'temporary directory', a language, a scope) — honor it, don't \
1941 re-litigate it. Resolve a constraint to where the file tools can actually act: 'a \
1942 temporary directory' is honored by a fresh gitignored scratch subdirectory INSIDE \
1943 the workspace (e.g. ./scratch-<name>), since paths outside the workspace are \
1944 rejected — state that assumption and proceed, don't ask.\n"
1945 } else {
1946 ""
1947 };
1948 format!(
1949 "You are Heartbit, a software-engineering lead in a terminal UI. You have your OWN tools \
1950 (read, search, edit, run code) for quick work, AND a team of specialist sub-agents you \
1951 delegate substantive work to. Match the response to the request:\n\n\
1952 ## How to decide (per request)\n\
1953 - **Conversational / simple questions** (greetings, \"what can you do\", a fact, an \
1954 explanation): answer DIRECTLY in prose. No tools, no delegation.\n\
1955 - **A SMALL, focused change you can finish in ~1–3 tool calls** (read one file, one edit, \
1956 run one command): do it YOURSELF.\n\
1957 - **A SUBSTANTIVE task — DELEGATE (this is the DEFAULT for real work).** Triggers: it \
1958 spans MULTIPLE files/areas/components; it has several independent parts; or it is a \
1959 broad request (implement a feature, add+wire+test something, review/audit/refactor \
1960 across the codebase, investigate-then-change). Break it into self-contained tasks and \
1961 {delegation_line} Parallel delegation is ONLY for tasks with NO shared write target AND \
1962 where no task needs another's output — delegated agents run ISOLATED and cannot see \
1963 each other's files. INTERDEPENDENT or shared-output work (e.g. building one project: \
1964 the manifest needs the directory, modules need the crate) must be done by YOU directly \
1965 or as ONE sequential task — never fanned out to N parallel same-agent tasks (they race \
1966 and thrash).{workflow_decision_line}\n{clarify_block}{discipline}\n\
1967 ## Principles\n\
1968 - Trivial → answer. Small & focused → do it yourself. Substantive or multi-part → \
1969 DELEGATE. Do NOT grind through a large multi-part task entirely yourself when \
1970 independent parts could run in parallel across the squad.\n\
1971 - When you do change code yourself, make the smallest correct change and verify it. Be \
1972 concise; show your work through tool use rather than narrating it.\n\
1973 - Each delegated task must be self-contained (include all the context the agent needs).\n\n\
1974 ## Available Sub-Agents\n{agent_list}{workflow_block}"
1975 )
1976}
1977
1978pub fn build_delegate_tool_schema(
1986 agents: &[(&str, &str, &[String])],
1987 dispatch_mode: DispatchMode,
1988) -> ToolDefinition {
1989 let agent_descriptions: Vec<serde_json::Value> = agents
1990 .iter()
1991 .map(|(name, desc, tools)| json!({"name": name, "description": desc, "tools": tools}))
1992 .collect();
1993
1994 let (description, tasks_schema) = match dispatch_mode {
1995 DispatchMode::Sequential => (
1996 format!(
1997 "Delegate a task to ONE sub-agent at a time. Wait for the result before \
1998 delegating to the next agent. Each task runs in isolation. \
1999 Write clear, self-contained task descriptions with all necessary context. \
2000 Available agents: {}",
2001 serde_json::to_string(&agent_descriptions)
2002 .expect("agent list serialization is infallible")
2003 ),
2004 json!({
2005 "type": "array",
2006 "items": {
2007 "type": "object",
2008 "properties": {
2009 "agent": {
2010 "type": "string",
2011 "description": "Name of the sub-agent"
2012 },
2013 "task": {
2014 "type": "string",
2015 "description": "Task instruction for the sub-agent"
2016 }
2017 },
2018 "required": ["agent", "task"]
2019 },
2020 "minItems": 1,
2021 "maxItems": 1
2022 }),
2023 ),
2024 DispatchMode::Parallel => (
2025 format!(
2026 "Delegate independent tasks to sub-agents for parallel execution. \
2027 Each task runs in isolation — agents cannot see each other's work. \
2028 ONLY for tasks with NO shared write target and NO cross-task dependency: \
2029 do NOT split ONE artifact (e.g. files under a shared directory, or steps where \
2030 one needs another's output) across parallel tasks — build interdependent work \
2031 as a SINGLE task or do it yourself. \
2032 Write clear, self-contained task descriptions with all necessary context. \
2033 Available agents: {}",
2034 serde_json::to_string(&agent_descriptions)
2035 .expect("agent list serialization is infallible")
2036 ),
2037 json!({
2038 "type": "array",
2039 "items": {
2040 "type": "object",
2041 "properties": {
2042 "agent": {
2043 "type": "string",
2044 "description": "Name of the sub-agent"
2045 },
2046 "task": {
2047 "type": "string",
2048 "description": "Task instruction for the sub-agent"
2049 }
2050 },
2051 "required": ["agent", "task"]
2052 },
2053 "minItems": 1
2054 }),
2055 ),
2056 };
2057
2058 ToolDefinition {
2059 name: "delegate_task".into(),
2060 description,
2061 input_schema: json!({
2062 "type": "object",
2063 "properties": {
2064 "tasks": tasks_schema
2065 },
2066 "required": ["tasks"]
2067 }),
2068 }
2069}
2070
2071pub(crate) fn build_form_squad_tool_schema(agents: &[(&str, &str, &[String])]) -> ToolDefinition {
2078 let agent_descriptions: Vec<serde_json::Value> = agents
2079 .iter()
2080 .map(|(name, desc, tools)| json!({"name": name, "description": desc, "tools": tools}))
2081 .collect();
2082
2083 ToolDefinition {
2084 name: "form_squad".into(),
2085 description: format!(
2086 "Dispatch per-agent tasks in parallel with a shared blackboard for intra-squad coordination. \
2087 Unlike delegate_task, squad agents can read each other's results via the blackboard. \
2088 Use this when agents benefit from shared state (e.g., building on each other's work, \
2089 coordinating on a shared artifact). Agents run concurrently. \
2090 Requires at least 2 tasks (one per agent). \
2091 Available agents: {}",
2092 serde_json::to_string(&agent_descriptions)
2093 .expect("agent list serialization is infallible")
2094 ),
2095 input_schema: json!({
2096 "type": "object",
2097 "properties": {
2098 "tasks": {
2099 "type": "array",
2100 "items": {
2101 "type": "object",
2102 "properties": {
2103 "agent": {
2104 "type": "string",
2105 "description": "Name of the sub-agent"
2106 },
2107 "task": {
2108 "type": "string",
2109 "description": "Task instruction for the sub-agent"
2110 }
2111 },
2112 "required": ["agent", "task"]
2113 },
2114 "minItems": 2,
2115 "description": "Per-agent tasks for the squad (minimum 2)"
2116 }
2117 },
2118 "required": ["tasks"]
2119 }),
2120 }
2121}
2122
2123#[derive(Clone, Default)]
2133pub struct SubAgentContextConfig {
2134 pub todo_store: Option<Arc<crate::tool::builtins::TodoStore>>,
2138 pub context_recall_store: Option<Arc<crate::agent::context_recall::ContextRecallStore>>,
2141 pub context_window_tokens: Option<u32>,
2143 pub replan_on_verify_fail: bool,
2146}
2147
2148#[derive(Default)]
2152pub struct SubAgentConfig {
2153 pub name: String,
2155 pub description: String,
2157 pub system_prompt: String,
2159 pub tools: Vec<Arc<dyn Tool>>,
2161 pub context_strategy: Option<ContextStrategy>,
2163 pub summarize_threshold: Option<u32>,
2165 pub tool_timeout: Option<Duration>,
2167 pub max_tool_output_bytes: Option<usize>,
2169 pub max_turns: Option<usize>,
2171 pub max_tokens: Option<u32>,
2173 pub response_schema: Option<serde_json::Value>,
2176 pub run_timeout: Option<Duration>,
2179 pub guardrails: Vec<Arc<dyn Guardrail>>,
2181 pub provider: Option<Arc<BoxedProvider>>,
2185 pub reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
2187 pub enable_reflection: Option<bool>,
2189 pub tool_output_compression_threshold: Option<usize>,
2191 pub max_tools_per_turn: Option<usize>,
2193 pub tool_profile: Option<super::tool_filter::ToolProfile>,
2195 pub max_identical_tool_calls: Option<u32>,
2197 pub max_fuzzy_identical_tool_calls: Option<u32>,
2199 pub max_tool_calls_per_turn: Option<u32>,
2201 pub session_prune_config: Option<crate::agent::pruner::SessionPruneConfig>,
2203 pub enable_recursive_summarization: Option<bool>,
2205 pub reflection_threshold: Option<u32>,
2207 pub consolidate_on_exit: Option<bool>,
2209 pub workspace: Option<std::path::PathBuf>,
2211 pub max_total_tokens: Option<u64>,
2213 pub audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
2215 pub audit_user_id: Option<String>,
2217 pub audit_tenant_id: Option<String>,
2219 #[allow(dead_code)]
2221 pub audit_delegation_chain: Vec<String>,
2222 pub context: SubAgentContextConfig,
2225}
2226
2227pub struct OrchestratorBuilder<P: LlmProvider> {
2239 provider: Arc<P>,
2240 sub_agents: Vec<SubAgentDef>,
2241 max_turns: usize,
2242 max_tokens: u32,
2243 context_strategy: Option<ContextStrategy>,
2244 summarize_threshold: Option<u32>,
2245 tool_timeout: Option<Duration>,
2246 max_tool_output_bytes: Option<usize>,
2247 shared_memory: Option<Arc<dyn Memory>>,
2248 memory_namespace_prefix: Option<String>,
2249 blackboard: Option<Arc<dyn Blackboard>>,
2250 knowledge_base: Option<Arc<dyn KnowledgeBase>>,
2251 on_text: Option<Arc<crate::llm::OnText>>,
2252 on_reasoning: Option<Arc<crate::llm::OnReasoning>>,
2253 on_approval: Option<Arc<crate::llm::OnApproval>>,
2254 on_event: Option<Arc<OnEvent>>,
2255 on_input: Option<Arc<crate::agent::OnInput>>,
2256 interrupt: Option<super::interrupt::InterruptHandle>,
2257 guardrails: Vec<Arc<dyn Guardrail>>,
2258 on_question: Option<Arc<OnQuestion>>,
2259 run_timeout: Option<Duration>,
2260 enable_squads: Option<bool>,
2261 reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
2262 enable_reflection: bool,
2263 tool_output_compression_threshold: Option<usize>,
2264 max_tools_per_turn: Option<usize>,
2265 max_identical_tool_calls: Option<u32>,
2266 max_fuzzy_identical_tool_calls: Option<u32>,
2267 max_tool_calls_per_turn: Option<u32>,
2268 permission_rules: super::permission::PermissionRuleset,
2269 instruction_text: Option<String>,
2270 learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
2271 lsp_manager: Option<Arc<crate::lsp::LspManager>>,
2272 observability_mode: Option<super::observability::ObservabilityMode>,
2273 dispatch_mode: DispatchMode,
2274 workspace: Option<std::path::PathBuf>,
2276 audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
2278 audit_user_id: Option<String>,
2280 audit_tenant_id: Option<String>,
2282 audit_delegation_chain: Vec<String>,
2284 allow_shared_write: bool,
2288 multi_agent_prompt: bool,
2291 spawn_config: Option<crate::types::SpawnConfig>,
2294 spawn_builtin_tools: Vec<Arc<dyn Tool>>,
2296 tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
2301 entry_mode: bool,
2306 entry_direct_tools: Vec<Arc<dyn Tool>>,
2309 entry_workflow_recipes: Vec<(String, String)>,
2312 entry_context: SubAgentContextConfig,
2315 entry_goal: Option<super::goal::GoalCondition>,
2319 entry_goal_judge: Option<Arc<BoxedProvider>>,
2324 entry_request_router: Option<Arc<super::router::RequestRouter>>,
2327}
2328
2329impl<P: LlmProvider + 'static> OrchestratorBuilder<P> {
2330 pub fn entry_agent(mut self, direct_tools: Vec<Arc<dyn Tool>>) -> Self {
2336 self.entry_mode = true;
2337 self.entry_direct_tools = direct_tools;
2338 self
2339 }
2340
2341 pub fn entry_workflow_recipes(mut self, recipes: Vec<(String, String)>) -> Self {
2343 self.entry_workflow_recipes = recipes;
2344 self
2345 }
2346
2347 pub fn entry_context(mut self, context: SubAgentContextConfig) -> Self {
2349 self.entry_context = context;
2350 self
2351 }
2352
2353 pub fn entry_goal(mut self, goal: super::goal::GoalCondition) -> Self {
2358 self.entry_goal = Some(goal);
2359 self
2360 }
2361
2362 pub fn entry_goal_judge(mut self, judge: Arc<BoxedProvider>) -> Self {
2367 self.entry_goal_judge = Some(judge);
2368 self
2369 }
2370
2371 pub fn entry_request_router(mut self, router: Arc<super::router::RequestRouter>) -> Self {
2375 self.entry_request_router = Some(router);
2376 self
2377 }
2378
2379 pub fn sub_agent(
2381 mut self,
2382 name: impl Into<String>,
2383 description: impl Into<String>,
2384 system_prompt: impl Into<String>,
2385 ) -> Self {
2386 let mut def = SubAgentDef::new(name, description, system_prompt);
2387 def.workspace = self.workspace.clone();
2388 def.audit_trail = self.audit_trail.clone();
2389 def.audit_user_id = self.audit_user_id.clone();
2390 def.audit_tenant_id = self.audit_tenant_id.clone();
2391 def.audit_delegation_chain = self.audit_delegation_chain.clone();
2392 self.sub_agents.push(def);
2393 self
2394 }
2395
2396 pub fn sub_agent_with_tools(
2398 mut self,
2399 name: impl Into<String>,
2400 description: impl Into<String>,
2401 system_prompt: impl Into<String>,
2402 tools: Vec<Arc<dyn Tool>>,
2403 ) -> Self {
2404 let mut def = SubAgentDef::new(name, description, system_prompt);
2405 def.tools = tools;
2406 def.workspace = self.workspace.clone();
2407 def.audit_trail = self.audit_trail.clone();
2408 def.audit_user_id = self.audit_user_id.clone();
2409 def.audit_tenant_id = self.audit_tenant_id.clone();
2410 def.audit_delegation_chain = self.audit_delegation_chain.clone();
2411 self.sub_agents.push(def);
2412 self
2413 }
2414
2415 pub fn sub_agent_full(mut self, config: SubAgentConfig) -> Self {
2417 let mut def = SubAgentDef::from(config);
2418 if def.workspace.is_none() {
2419 def.workspace = self.workspace.clone();
2420 }
2421 if def.audit_trail.is_none() {
2422 def.audit_trail = self.audit_trail.clone();
2423 }
2424 if def.audit_user_id.is_none() {
2425 def.audit_user_id = self.audit_user_id.clone();
2426 }
2427 if def.audit_tenant_id.is_none() {
2428 def.audit_tenant_id = self.audit_tenant_id.clone();
2429 }
2430 if def.audit_delegation_chain.is_empty() {
2431 def.audit_delegation_chain = self.audit_delegation_chain.clone();
2432 }
2433 self.sub_agents.push(def);
2434 self
2435 }
2436
2437 pub fn max_turns(mut self, max_turns: usize) -> Self {
2439 self.max_turns = max_turns;
2440 self
2441 }
2442
2443 pub fn max_tokens(mut self, max_tokens: u32) -> Self {
2445 self.max_tokens = max_tokens;
2446 self
2447 }
2448
2449 pub fn context_strategy(mut self, strategy: ContextStrategy) -> Self {
2451 self.context_strategy = Some(strategy);
2452 self
2453 }
2454
2455 pub fn summarize_threshold(mut self, threshold: u32) -> Self {
2457 self.summarize_threshold = Some(threshold);
2458 self
2459 }
2460
2461 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
2463 self.tool_timeout = Some(timeout);
2464 self
2465 }
2466
2467 pub fn max_tool_output_bytes(mut self, max: usize) -> Self {
2469 self.max_tool_output_bytes = Some(max);
2470 self
2471 }
2472
2473 pub fn shared_memory(mut self, memory: Arc<dyn Memory>) -> Self {
2477 self.shared_memory = Some(memory);
2478 self
2479 }
2480
2481 pub fn memory_namespace_prefix(mut self, prefix: impl Into<String>) -> Self {
2485 self.memory_namespace_prefix = Some(prefix.into());
2486 self
2487 }
2488
2489 pub fn allow_shared_write(mut self, allow: bool) -> Self {
2495 self.allow_shared_write = allow;
2496 self
2497 }
2498
2499 pub fn multi_agent_prompt(mut self, enabled: bool) -> Self {
2502 self.multi_agent_prompt = enabled;
2503 self
2504 }
2505
2506 pub fn spawn_config(
2512 mut self,
2513 config: crate::types::SpawnConfig,
2514 builtin_tools: Vec<Arc<dyn Tool>>,
2515 ) -> Self {
2516 self.spawn_config = Some(config);
2517 self.spawn_builtin_tools = builtin_tools;
2518 self
2519 }
2520
2521 pub fn tenant_tracker(
2530 mut self,
2531 tracker: Arc<crate::agent::tenant_tracker::TenantTokenTracker>,
2532 ) -> Self {
2533 self.tenant_tracker = Some(tracker);
2534 self
2535 }
2536
2537 pub fn blackboard(mut self, blackboard: Arc<dyn Blackboard>) -> Self {
2543 self.blackboard = Some(blackboard);
2544 self
2545 }
2546
2547 pub fn knowledge(mut self, kb: Arc<dyn KnowledgeBase>) -> Self {
2552 self.knowledge_base = Some(kb);
2553 self
2554 }
2555
2556 pub fn on_text(mut self, callback: Arc<crate::llm::OnText>) -> Self {
2560 self.on_text = Some(callback);
2561 self
2562 }
2563
2564 pub fn on_reasoning(mut self, callback: Arc<crate::llm::OnReasoning>) -> Self {
2567 self.on_reasoning = Some(callback);
2568 self
2569 }
2570
2571 pub fn on_approval(mut self, callback: Arc<crate::llm::OnApproval>) -> Self {
2583 self.on_approval = Some(callback);
2584 self
2585 }
2586
2587 pub fn on_input(mut self, callback: Arc<crate::agent::OnInput>) -> Self {
2593 self.on_input = Some(callback);
2594 self
2595 }
2596
2597 pub fn interrupt(mut self, handle: super::interrupt::InterruptHandle) -> Self {
2601 self.interrupt = Some(handle);
2602 self
2603 }
2604
2605 pub fn learned_permissions(
2607 mut self,
2608 learned: Arc<std::sync::Mutex<super::permission::LearnedPermissions>>,
2609 ) -> Self {
2610 self.learned_permissions = Some(learned);
2611 self
2612 }
2613
2614 pub fn lsp_manager(mut self, manager: Arc<crate::lsp::LspManager>) -> Self {
2616 self.lsp_manager = Some(manager);
2617 self
2618 }
2619
2620 pub fn on_event(mut self, callback: Arc<OnEvent>) -> Self {
2626 self.on_event = Some(callback);
2627 self
2628 }
2629
2630 pub fn guardrail(mut self, guardrail: Arc<dyn Guardrail>) -> Self {
2632 self.guardrails.push(guardrail);
2633 self
2634 }
2635
2636 pub fn guardrails(mut self, guardrails: Vec<Arc<dyn Guardrail>>) -> Self {
2638 self.guardrails.extend(guardrails);
2639 self
2640 }
2641
2642 pub fn on_question(mut self, callback: Arc<OnQuestion>) -> Self {
2644 self.on_question = Some(callback);
2645 self
2646 }
2647
2648 pub fn run_timeout(mut self, timeout: Duration) -> Self {
2651 self.run_timeout = Some(timeout);
2652 self
2653 }
2654
2655 pub fn enable_squads(mut self, enable: bool) -> Self {
2663 self.enable_squads = Some(enable);
2664 self
2665 }
2666
2667 pub fn reasoning_effort(mut self, effort: crate::llm::types::ReasoningEffort) -> Self {
2669 self.reasoning_effort = Some(effort);
2670 self
2671 }
2672
2673 pub fn enable_reflection(mut self, enabled: bool) -> Self {
2675 self.enable_reflection = enabled;
2676 self
2677 }
2678
2679 pub fn tool_output_compression_threshold(mut self, threshold: usize) -> Self {
2681 self.tool_output_compression_threshold = Some(threshold);
2682 self
2683 }
2684
2685 pub fn max_tools_per_turn(mut self, max: usize) -> Self {
2687 self.max_tools_per_turn = Some(max);
2688 self
2689 }
2690
2691 pub fn max_identical_tool_calls(mut self, max: u32) -> Self {
2693 self.max_identical_tool_calls = Some(max);
2694 self
2695 }
2696
2697 pub fn max_fuzzy_identical_tool_calls(mut self, max: u32) -> Self {
2699 self.max_fuzzy_identical_tool_calls = Some(max);
2700 self
2701 }
2702
2703 pub fn max_tool_calls_per_turn(mut self, cap: u32) -> Self {
2705 self.max_tool_calls_per_turn = Some(cap);
2706 self
2707 }
2708
2709 pub fn permission_rules(mut self, rules: super::permission::PermissionRuleset) -> Self {
2711 self.permission_rules = rules;
2712 self
2713 }
2714
2715 pub fn instruction_text(mut self, text: impl Into<String>) -> Self {
2717 let text = text.into();
2718 if !text.is_empty() {
2719 self.instruction_text = Some(text);
2720 }
2721 self
2722 }
2723
2724 pub fn observability_mode(mut self, mode: super::observability::ObservabilityMode) -> Self {
2726 self.observability_mode = Some(mode);
2727 self
2728 }
2729
2730 pub fn dispatch_mode(mut self, mode: DispatchMode) -> Self {
2736 self.dispatch_mode = mode;
2737 self
2738 }
2739
2740 pub fn workspace(mut self, path: impl Into<std::path::PathBuf>) -> Self {
2743 self.workspace = Some(path.into());
2744 self
2745 }
2746
2747 pub fn audit_trail(mut self, trail: Arc<dyn super::audit::AuditTrail>) -> Self {
2751 self.audit_trail = Some(trail);
2752 self
2753 }
2754
2755 pub fn audit_user_context(
2758 mut self,
2759 user_id: impl Into<String>,
2760 tenant_id: impl Into<String>,
2761 ) -> Self {
2762 self.audit_user_id = Some(user_id.into());
2763 self.audit_tenant_id = Some(tenant_id.into());
2764 self
2765 }
2766
2767 pub fn audit_delegation_chain(mut self, chain: Vec<String>) -> Self {
2769 self.audit_delegation_chain = chain;
2770 self
2771 }
2772
2773 pub fn build(mut self) -> Result<Orchestrator<P>, Error> {
2775 if self.multi_agent_prompt {
2777 for agent in &mut self.sub_agents {
2778 agent
2779 .system_prompt
2780 .push_str(&crate::agent::prompts::render_collab_prompt(
2781 &agent.name,
2782 &agent.description,
2783 ));
2784 }
2785 }
2786
2787 {
2789 let mut seen = std::collections::HashSet::new();
2790 for agent in &self.sub_agents {
2791 if agent.name.is_empty() {
2792 return Err(Error::Config("sub-agent name must not be empty".into()));
2793 }
2794 if !seen.insert(&agent.name) {
2795 return Err(Error::Config(format!(
2796 "duplicate sub-agent name: '{}'",
2797 agent.name
2798 )));
2799 }
2800 if agent.max_turns == Some(0) {
2801 return Err(Error::Config(format!(
2802 "sub-agent '{}': max_turns must be > 0",
2803 agent.name
2804 )));
2805 }
2806 if agent.max_tokens == Some(0) {
2807 return Err(Error::Config(format!(
2808 "sub-agent '{}': max_tokens must be > 0",
2809 agent.name
2810 )));
2811 }
2812 }
2813 }
2814
2815 if self.sub_agents.is_empty() {
2816 tracing::warn!(
2817 "orchestrator built with no sub-agents — delegate_task tool will list no agents"
2818 );
2819 }
2820
2821 let squads_enabled = if self.dispatch_mode == DispatchMode::Sequential {
2824 false
2825 } else {
2826 self.enable_squads.unwrap_or(self.sub_agents.len() >= 2)
2827 };
2828 if squads_enabled && self.sub_agents.len() < 2 {
2829 tracing::warn!(
2830 "enable_squads is true but fewer than 2 agents are registered — \
2831 form_squad requires at least 2 agents to be useful"
2832 );
2833 }
2834
2835 let tool_names: Vec<Vec<String>> = self
2836 .sub_agents
2837 .iter()
2838 .map(|a| a.tools.iter().map(|t| t.definition().name).collect())
2839 .collect();
2840 let triples: Vec<(&str, &str, &[String])> = self
2841 .sub_agents
2842 .iter()
2843 .zip(tool_names.iter())
2844 .map(|(a, names)| (a.name.as_str(), a.description.as_str(), names.as_slice()))
2845 .collect();
2846 let mut system = if self.entry_mode {
2847 let recipes: Vec<(&str, &str)> = self
2850 .entry_workflow_recipes
2851 .iter()
2852 .map(|(n, d)| (n.as_str(), d.as_str()))
2853 .collect();
2854 let caps = EntryCaps {
2859 ask_user: self
2860 .entry_direct_tools
2861 .iter()
2862 .any(|t| t.definition().name == "question"),
2863 set_goal: self.entry_goal_judge.is_some(),
2864 set_scope: self
2865 .entry_direct_tools
2866 .iter()
2867 .any(|t| t.definition().name == "set_scope"),
2868 };
2869 build_entry_agent_prompt_ext(
2870 &triples,
2871 squads_enabled,
2872 self.dispatch_mode,
2873 &recipes,
2874 caps,
2875 )
2876 } else {
2877 build_system_prompt(&triples, squads_enabled, self.dispatch_mode)
2878 };
2879 if let (Some(uid), Some(tid)) = (&self.audit_user_id, &self.audit_tenant_id) {
2881 system.push_str(&format!(
2882 "\n---\nYou are operating on behalf of **{uid}** in organization **{tid}**.\nKeep this user's information private. Do not share their data with other users."
2883 ));
2884 }
2885 let cached_definition = build_delegate_tool_schema(&triples, self.dispatch_mode);
2886 let form_squad_definition = if squads_enabled {
2887 Some(build_form_squad_tool_schema(&triples))
2888 } else {
2889 None
2890 };
2891 drop(triples);
2893 drop(tool_names);
2894
2895 let sub_agent_tokens = Arc::new(Mutex::new(TokenUsage::default()));
2896
2897 let shared_provider = Arc::new(BoxedProvider::from_arc(self.provider.clone()));
2898
2899 let agent_pool = if squads_enabled {
2901 Some(self.sub_agents.clone())
2902 } else {
2903 None
2904 };
2905
2906 let resolved_mode = self
2907 .observability_mode
2908 .unwrap_or(super::observability::ObservabilityMode::Production);
2909
2910 let spawn_tool_data = if let Some(spawn_cfg) = self.spawn_config.take() {
2912 let mut tool_pool = std::collections::HashMap::new();
2913 for tool in &self.spawn_builtin_tools {
2914 let name = tool.definition().name;
2915 if spawn_cfg.tool_allowlist.contains(&name) {
2916 tool_pool.insert(name, tool.clone());
2917 }
2918 }
2919 for allowed in &spawn_cfg.tool_allowlist {
2921 if !tool_pool.contains_key(allowed) {
2922 return Err(Error::Config(format!(
2923 "orchestrator.spawn.tool_allowlist: unknown tool '{}'. \
2924 Available builtin tools: [{}]",
2925 allowed,
2926 self.spawn_builtin_tools
2927 .iter()
2928 .map(|t| t.definition().name)
2929 .collect::<Vec<_>>()
2930 .join(", ")
2931 )));
2932 }
2933 }
2934 tool_pool.remove("delegate_task");
2936 tool_pool.remove("form_squad");
2937 tool_pool.remove("spawn_agent");
2938
2939 let cached_definition = SpawnAgentTool::build_definition(&spawn_cfg);
2940
2941 system.push_str(
2942 "\n\n## Dynamic Agent Spawning\n\
2943 You also have the **spawn_agent** tool to create specialist agents at runtime \
2944 when no pre-configured agent fits the task. Use this as a secondary option — \
2945 prefer delegating to existing agents when they match the need.",
2946 );
2947
2948 Some((spawn_cfg, tool_pool, cached_definition))
2949 } else {
2950 None
2951 };
2952
2953 let delegate_tool: Arc<dyn Tool> = Arc::new(DelegateTaskTool {
2954 shared_provider: shared_provider.clone(),
2955 sub_agents: self.sub_agents,
2956 max_turns: self.max_turns,
2957 max_tokens: self.max_tokens,
2958 permission_rules: self.permission_rules.clone(),
2959 accumulated_tokens: sub_agent_tokens.clone(),
2960 shared_memory: self.shared_memory.clone(),
2961 memory_namespace_prefix: self.memory_namespace_prefix.clone(),
2962 blackboard: self.blackboard.clone(),
2963 knowledge_base: self.knowledge_base.clone(),
2964 cached_definition,
2965 on_event: self.on_event.clone(),
2966 lsp_manager: self.lsp_manager.clone(),
2967 observability_mode: resolved_mode,
2968 allow_shared_write: self.allow_shared_write,
2969 tenant_tracker: self.tenant_tracker.clone(),
2970 guardrails: self.guardrails.clone(),
2971 on_approval: self.on_approval.clone(),
2972 learned_permissions: self.learned_permissions.clone(),
2973 fanout_refused: std::sync::Mutex::new(None),
2974 });
2975
2976 let mut runner_builder = AgentRunner::builder(self.provider)
2977 .name("orchestrator")
2978 .system_prompt(system)
2979 .tool(delegate_tool)
2980 .max_turns(self.max_turns)
2981 .max_tokens(self.max_tokens);
2982
2983 if self.entry_mode {
2987 if !self.entry_direct_tools.is_empty() {
2988 runner_builder = runner_builder.tools(std::mem::take(&mut self.entry_direct_tools));
2989 }
2990 let cx = std::mem::take(&mut self.entry_context);
2991 if let Some(store) = cx.todo_store {
2992 runner_builder = runner_builder.todo_store(store);
2993 }
2994 if cx.replan_on_verify_fail {
2995 runner_builder = runner_builder.replan_on_verify_fail(true);
2996 }
2997 if let Some(window) = cx.context_window_tokens {
2998 runner_builder = runner_builder.context_window_tokens(window);
2999 }
3000 if let Some(store) = cx.context_recall_store {
3001 runner_builder = runner_builder
3005 .context_recall_store(store)
3006 .session_prune_config(crate::agent::pruner::SessionPruneConfig {
3007 keep_recent_n: 3,
3008 pruned_tool_result_max_bytes: 1024,
3009 ..Default::default()
3010 });
3011 }
3012 let goal_slot: super::goal::GoalSlot =
3017 Arc::new(std::sync::RwLock::new(self.entry_goal.take()));
3018 if let Some(judge) = self.entry_goal_judge.take() {
3019 runner_builder = runner_builder.tool(Arc::new(
3020 crate::tool::set_goal::SetGoalTool::new(goal_slot.clone(), judge),
3021 ));
3022 }
3023 runner_builder = runner_builder.goal_slot(goal_slot);
3024 if let Some(router) = self.entry_request_router.take() {
3026 runner_builder = runner_builder.request_router(router);
3027 }
3028 let mut nudge_tools = vec!["delegate_task".to_string()];
3034 if squads_enabled {
3035 nudge_tools.push("form_squad".into());
3036 }
3037 if !self.entry_workflow_recipes.is_empty() {
3038 nudge_tools.push("run_workflow".into());
3039 }
3040 runner_builder = runner_builder.delegation_nudge(super::runner::DelegationNudge {
3041 after_tool_calls: ENTRY_DELEGATION_NUDGE_AFTER,
3042 tool_names: nudge_tools,
3043 });
3044 }
3045
3046 if let Some(agent_pool) = agent_pool {
3048 let squad_def = form_squad_definition.expect("squad definition computed when enabled");
3050 let form_squad_tool: Arc<dyn Tool> = Arc::new(FormSquadTool {
3051 shared_provider: shared_provider.clone(),
3052 agent_pool,
3053 default_max_turns: self.max_turns,
3054 default_max_tokens: self.max_tokens,
3055 permission_rules: self.permission_rules.clone(),
3056 accumulated_tokens: sub_agent_tokens.clone(),
3057 shared_memory: self.shared_memory.clone(),
3058 memory_namespace_prefix: self.memory_namespace_prefix.clone(),
3059 blackboard: self.blackboard.clone(),
3060 knowledge_base: self.knowledge_base.clone(),
3061 on_event: self.on_event.clone(),
3062 lsp_manager: self.lsp_manager.clone(),
3063 cached_definition: squad_def,
3064 observability_mode: resolved_mode,
3065 allow_shared_write: self.allow_shared_write,
3066 tenant_tracker: self.tenant_tracker.clone(),
3067 guardrails: self.guardrails.clone(),
3068 on_approval: self.on_approval.clone(),
3069 learned_permissions: self.learned_permissions.clone(),
3070 });
3071 runner_builder = runner_builder.tool(form_squad_tool);
3072 }
3073
3074 if let Some((spawn_cfg, tool_pool, spawn_def)) = spawn_tool_data {
3076 let spawn_tool: Arc<dyn Tool> = Arc::new(SpawnAgentTool {
3077 shared_provider: shared_provider.clone(),
3078 spawn_config: spawn_cfg,
3079 tool_pool,
3080 spawn_count: Arc::new(std::sync::atomic::AtomicU32::new(0)),
3081 spawned_names: Arc::new(Mutex::new(std::collections::HashSet::new())),
3082 accumulated_tokens: sub_agent_tokens.clone(),
3083 permission_rules: self.permission_rules.clone(),
3084 shared_memory: self.shared_memory.clone(),
3085 memory_namespace_prefix: self.memory_namespace_prefix.clone(),
3086 on_event: self.on_event.clone(),
3087 lsp_manager: self.lsp_manager.clone(),
3088 observability_mode: resolved_mode,
3089 workspace: self.workspace.clone(),
3090 guardrails: self.guardrails.clone(),
3091 audit_trail: self.audit_trail.clone(),
3092 audit_user_id: self.audit_user_id.clone(),
3093 audit_tenant_id: self.audit_tenant_id.clone(),
3094 audit_delegation_chain: self.audit_delegation_chain.clone(),
3095 cached_definition: spawn_def,
3096 tenant_tracker: self.tenant_tracker.clone(),
3097 on_approval: self.on_approval.clone(),
3098 learned_permissions: self.learned_permissions.clone(),
3099 });
3100 runner_builder = runner_builder.tool(spawn_tool);
3101 }
3102
3103 if let Some(ref memory) = self.shared_memory {
3106 let orch_ns = self
3107 .memory_namespace_prefix
3108 .as_deref()
3109 .unwrap_or("orchestrator");
3110 let mem_scope = crate::auth::TenantScope::from_audit_fields(
3111 self.audit_tenant_id.as_deref(),
3112 self.audit_user_id.as_deref(),
3113 );
3114 let mem_tools = crate::memory::shared_tools::shared_memory_tools(
3115 memory.clone(),
3116 orch_ns,
3117 mem_scope,
3118 self.allow_shared_write,
3119 );
3120 runner_builder = runner_builder.tools(mem_tools);
3121 }
3122
3123 if let Some(strategy) = self.context_strategy {
3124 runner_builder = runner_builder.context_strategy(strategy);
3125 }
3126 if let Some(threshold) = self.summarize_threshold {
3127 runner_builder = runner_builder.summarize_threshold(threshold);
3128 }
3129 if let Some(timeout) = self.tool_timeout {
3130 runner_builder = runner_builder.tool_timeout(timeout);
3131 }
3132 if let Some(max) = self.max_tool_output_bytes {
3133 runner_builder = runner_builder.max_tool_output_bytes(max);
3134 }
3135 if let Some(on_text) = self.on_text {
3136 runner_builder = runner_builder.on_text(on_text);
3137 }
3138 if let Some(on_reasoning) = self.on_reasoning {
3139 runner_builder = runner_builder.on_reasoning(on_reasoning);
3140 }
3141 if let Some(on_approval) = self.on_approval {
3142 runner_builder = runner_builder.on_approval(on_approval);
3143 }
3144 if let Some(on_input) = self.on_input {
3148 runner_builder = runner_builder.on_input(on_input);
3149 }
3150 if let Some(interrupt) = self.interrupt {
3151 runner_builder = runner_builder.interrupt(interrupt);
3152 }
3153 if let Some(learned) = self.learned_permissions {
3154 runner_builder = runner_builder.learned_permissions(learned);
3155 }
3156 if let Some(lsp) = self.lsp_manager {
3157 runner_builder = runner_builder.lsp_manager(lsp);
3158 }
3159 if let Some(on_event) = self.on_event {
3160 runner_builder = runner_builder.on_event(on_event);
3161 }
3162 if !self.guardrails.is_empty() {
3163 runner_builder = runner_builder.guardrails(self.guardrails);
3164 }
3165 if let Some(on_question) = self.on_question {
3166 runner_builder = runner_builder.on_question(on_question);
3167 }
3168 if let Some(timeout) = self.run_timeout {
3169 runner_builder = runner_builder.run_timeout(timeout);
3170 }
3171 if let Some(effort) = self.reasoning_effort {
3172 runner_builder = runner_builder.reasoning_effort(effort);
3173 }
3174 if self.enable_reflection {
3175 runner_builder = runner_builder.enable_reflection(true);
3176 }
3177 if let Some(threshold) = self.tool_output_compression_threshold {
3178 runner_builder = runner_builder.tool_output_compression_threshold(threshold);
3179 }
3180 if let Some(max) = self.max_tools_per_turn {
3181 runner_builder = runner_builder.max_tools_per_turn(max);
3182 }
3183 if let Some(max) = self.max_identical_tool_calls {
3184 runner_builder = runner_builder.max_identical_tool_calls(max);
3185 }
3186 if let Some(max) = self.max_fuzzy_identical_tool_calls {
3187 runner_builder = runner_builder.max_fuzzy_identical_tool_calls(max);
3188 }
3189 if let Some(cap) = self.max_tool_calls_per_turn {
3190 runner_builder = runner_builder.max_tool_calls_per_turn(cap);
3191 }
3192 if !self.permission_rules.is_empty() {
3193 runner_builder = runner_builder.permission_rules(self.permission_rules);
3194 }
3195 if let Some(text) = self.instruction_text {
3196 runner_builder = runner_builder.instruction_text(text);
3197 }
3198 if let Some(mode) = self.observability_mode {
3199 runner_builder = runner_builder.observability_mode(mode);
3200 }
3201 if let Some(trail) = self.audit_trail {
3202 runner_builder = runner_builder.audit_trail(trail);
3203 }
3204 if let Some(uid) = self.audit_user_id
3205 && let Some(tid) = self.audit_tenant_id
3206 {
3207 runner_builder = runner_builder.audit_user_context(uid, tid);
3208 }
3209 if !self.audit_delegation_chain.is_empty() {
3210 runner_builder =
3211 runner_builder.audit_delegation_chain(self.audit_delegation_chain.clone());
3212 }
3213 if let Some(tracker) = self.tenant_tracker {
3214 runner_builder = runner_builder.tenant_tracker(tracker);
3215 }
3216
3217 let runner = runner_builder.build()?;
3218
3219 Ok(Orchestrator {
3220 runner,
3221 sub_agent_tokens,
3222 })
3223 }
3224}
3225
3226#[cfg(test)]
3227mod tests {
3228 use super::*;
3229 use crate::llm::types::{
3230 CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
3231 };
3232 use crate::tool::ToolOutput;
3233 use std::sync::Mutex;
3234
3235 struct MockProvider {
3236 responses: Mutex<Vec<CompletionResponse>>,
3237 captured_requests: Mutex<Vec<CompletionRequest>>,
3240 }
3241
3242 impl MockProvider {
3243 fn new(responses: Vec<CompletionResponse>) -> Self {
3244 Self {
3245 responses: Mutex::new(responses),
3246 captured_requests: Mutex::new(Vec::new()),
3247 }
3248 }
3249 }
3250
3251 impl LlmProvider for MockProvider {
3252 async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, Error> {
3253 self.captured_requests
3254 .lock()
3255 .expect("capture lock poisoned")
3256 .push(request);
3257 let mut responses = self.responses.lock().expect("mock lock poisoned");
3258 if responses.is_empty() {
3259 return Err(Error::Agent("no more mock responses".into()));
3260 }
3261 Ok(responses.remove(0))
3262 }
3263
3264 fn model_name(&self) -> Option<&str> {
3265 Some("mock-model-v1")
3266 }
3267 }
3268
3269 struct MockTool {
3271 def: crate::llm::types::ToolDefinition,
3272 response: String,
3273 }
3274
3275 impl MockTool {
3276 fn new(name: &str, response: &str) -> Self {
3277 Self {
3278 def: crate::llm::types::ToolDefinition {
3279 name: name.into(),
3280 description: format!("Mock {name}"),
3281 input_schema: json!({"type": "object"}),
3282 },
3283 response: response.into(),
3284 }
3285 }
3286 }
3287
3288 impl crate::tool::Tool for MockTool {
3289 fn definition(&self) -> crate::llm::types::ToolDefinition {
3290 self.def.clone()
3291 }
3292
3293 fn execute(
3294 &self,
3295 _ctx: &crate::ExecutionContext,
3296 _input: serde_json::Value,
3297 ) -> std::pin::Pin<
3298 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
3299 > {
3300 let response = self.response.clone();
3301 Box::pin(async move { Ok(ToolOutput::success(response)) })
3302 }
3303 }
3304
3305 struct TrackedTool {
3308 def: crate::llm::types::ToolDefinition,
3309 executed: Arc<std::sync::atomic::AtomicBool>,
3310 }
3311
3312 impl TrackedTool {
3313 #[allow(clippy::type_complexity)]
3314 fn make(
3315 name: &str,
3316 ) -> (
3317 Arc<dyn crate::tool::Tool>,
3318 Arc<std::sync::atomic::AtomicBool>,
3319 ) {
3320 let executed = Arc::new(std::sync::atomic::AtomicBool::new(false));
3321 let tool = Arc::new(Self {
3322 def: crate::llm::types::ToolDefinition {
3323 name: name.into(),
3324 description: format!("Tracked {name}"),
3325 input_schema: json!({"type": "object"}),
3326 },
3327 executed: executed.clone(),
3328 });
3329 (tool, executed)
3330 }
3331 }
3332
3333 impl crate::tool::Tool for TrackedTool {
3334 fn definition(&self) -> crate::llm::types::ToolDefinition {
3335 self.def.clone()
3336 }
3337
3338 fn execute(
3339 &self,
3340 _ctx: &crate::ExecutionContext,
3341 _input: serde_json::Value,
3342 ) -> std::pin::Pin<
3343 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
3344 > {
3345 self.executed
3346 .store(true, std::sync::atomic::Ordering::SeqCst);
3347 Box::pin(async { Ok(ToolOutput::success("mutated")) })
3348 }
3349 }
3350
3351 #[allow(clippy::type_complexity)]
3354 fn deny_danger_approval() -> (Arc<crate::llm::OnApproval>, Arc<Mutex<Vec<String>>>) {
3355 let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
3356 let seen_cb = seen.clone();
3357 let cb: Arc<crate::llm::OnApproval> = Arc::new(move |calls| {
3358 let mut guard = seen_cb.lock().expect("seen lock");
3359 for call in calls {
3360 guard.push(call.name.clone());
3361 }
3362 if calls.iter().any(|c| c.name == "danger_tool") {
3363 crate::llm::ApprovalDecision::Deny
3364 } else {
3365 crate::llm::ApprovalDecision::Allow
3366 }
3367 });
3368 (cb, seen)
3369 }
3370
3371 #[test]
3372 fn system_prompt_includes_agents() {
3373 let tools_a = vec!["web_search".to_string(), "read_file".to_string()];
3374 let tools_b: Vec<String> = vec![];
3375 let agents: Vec<(&str, &str, &[String])> = vec![
3376 ("researcher", "Research specialist", tools_a.as_slice()),
3377 ("coder", "Coding expert", tools_b.as_slice()),
3378 ];
3379
3380 let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3381 assert!(prompt.contains("researcher"));
3382 assert!(prompt.contains("Research specialist"));
3383 assert!(prompt.contains("coder"));
3384 assert!(prompt.contains("Tools: web_search, read_file"));
3385 assert!(prompt.contains("Tools: (none)"));
3386 assert!(
3388 prompt.contains("Decision Process"),
3389 "prompt should contain Decision Process section: {prompt}"
3390 );
3391 assert!(
3392 prompt.contains("Effort Scaling"),
3393 "prompt should contain Effort Scaling section: {prompt}"
3394 );
3395 assert!(
3396 prompt.contains("Task Quality"),
3397 "prompt should contain Task Quality section: {prompt}"
3398 );
3399 assert!(
3400 prompt.contains("DECOMPOSE"),
3401 "prompt should contain decomposition guidance: {prompt}"
3402 );
3403 }
3404
3405 #[test]
3406 fn system_prompt_shows_tool_names() {
3407 let tools = vec![
3408 "web_search".to_string(),
3409 "read_file".to_string(),
3410 "knowledge_search".to_string(),
3411 ];
3412 let no_tools: Vec<String> = vec![];
3413 let agents: Vec<(&str, &str, &[String])> = vec![
3414 ("researcher", "Research specialist", tools.as_slice()),
3415 ("analyst", "Analytical thinker", no_tools.as_slice()),
3416 ];
3417
3418 let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3419 assert!(
3420 prompt.contains("Tools: web_search, read_file, knowledge_search"),
3421 "prompt should list tool names: {prompt}"
3422 );
3423 assert!(
3424 prompt.contains("Tools: (none)"),
3425 "prompt should show (none) for agents without tools: {prompt}"
3426 );
3427 }
3428
3429 #[test]
3430 fn system_prompt_sequential_says_one_at_a_time() {
3431 let tools: Vec<String> = vec![];
3432 let agents: Vec<(&str, &str, &[String])> =
3433 vec![("builder", "Builds stuff", tools.as_slice())];
3434 let prompt = build_system_prompt(&agents, false, DispatchMode::Sequential);
3435 assert!(
3436 prompt.contains("ONE agent at a time"),
3437 "sequential prompt should say one at a time: {prompt}"
3438 );
3439 assert!(
3440 !prompt.contains("parallel execution"),
3441 "sequential prompt should not mention parallel: {prompt}"
3442 );
3443 }
3444
3445 #[test]
3446 fn system_prompt_parallel_says_parallel() {
3447 let tools: Vec<String> = vec![];
3448 let agents: Vec<(&str, &str, &[String])> =
3449 vec![("builder", "Builds stuff", tools.as_slice())];
3450 let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3451 assert!(
3452 prompt.contains("parallel execution"),
3453 "parallel prompt should mention parallel: {prompt}"
3454 );
3455 }
3456
3457 #[test]
3458 fn delegate_schema_sequential_max_items_1() {
3459 let tools: Vec<String> = vec![];
3460 let agents: Vec<(&str, &str, &[String])> =
3461 vec![("builder", "Builds stuff", tools.as_slice())];
3462 let def = build_delegate_tool_schema(&agents, DispatchMode::Sequential);
3463 let tasks = &def.input_schema["properties"]["tasks"];
3464 assert_eq!(
3465 tasks["maxItems"], 1,
3466 "sequential schema should have maxItems=1: {tasks}"
3467 );
3468 assert!(
3469 def.description.contains("ONE sub-agent"),
3470 "sequential description should say ONE: {}",
3471 def.description
3472 );
3473 }
3474
3475 #[test]
3476 fn delegate_schema_parallel_no_max_items() {
3477 let tools: Vec<String> = vec![];
3478 let agents: Vec<(&str, &str, &[String])> =
3479 vec![("builder", "Builds stuff", tools.as_slice())];
3480 let def = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3481 let tasks = &def.input_schema["properties"]["tasks"];
3482 assert!(
3483 tasks.get("maxItems").is_none(),
3484 "parallel schema should not have maxItems: {tasks}"
3485 );
3486 }
3487
3488 #[tokio::test]
3489 async fn sequential_dispatch_disables_squads() {
3490 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
3494 content: vec![ContentBlock::Text {
3495 text: "done".into(),
3496 }],
3497 stop_reason: StopReason::EndTurn,
3498 reasoning: None,
3499 usage: TokenUsage::default(),
3500 model: None,
3501 }]));
3502 let mut orch = Orchestrator::builder(provider)
3503 .sub_agent("a", "Agent A", "prompt a")
3504 .sub_agent("b", "Agent B", "prompt b")
3505 .dispatch_mode(DispatchMode::Sequential)
3506 .build()
3507 .unwrap();
3508 let output = orch.run("test").await.unwrap();
3509 assert_eq!(output.result, "done");
3510 }
3513
3514 #[tokio::test]
3515 async fn orchestrator_runs_multi_turn_via_on_input() {
3516 fn text(t: &str) -> CompletionResponse {
3520 CompletionResponse {
3521 content: vec![ContentBlock::Text { text: t.into() }],
3522 stop_reason: StopReason::EndTurn,
3523 reasoning: None,
3524 usage: TokenUsage::default(),
3525 model: None,
3526 }
3527 }
3528 let provider = Arc::new(MockProvider::new(vec![text("first"), text("second")]));
3529 let inputs = Arc::new(std::sync::Mutex::new(vec![Some("again".to_string()), None]));
3530 let on_input: Arc<crate::agent::OnInput> = {
3531 let inputs = inputs.clone();
3532 Arc::new(move || {
3533 let inputs = inputs.clone();
3534 Box::pin(async move {
3535 let mut g = inputs.lock().expect("lock");
3536 if g.is_empty() { None } else { g.remove(0) }
3537 })
3538 })
3539 };
3540 let mut orch = Orchestrator::builder(provider)
3541 .sub_agent("a", "Agent A", "prompt a")
3542 .sub_agent("b", "Agent B", "prompt b")
3543 .on_input(on_input)
3544 .build()
3545 .unwrap();
3546 let output = orch.run("start").await.unwrap();
3547 assert_eq!(
3548 output.result, "second",
3549 "a single run() drives the full multi-turn session via on_input"
3550 );
3551 }
3552
3553 #[tokio::test]
3554 async fn orchestrator_interrupt_ends_turn_then_continues() {
3555 use crate::agent::interrupt::InterruptHandle;
3558 fn text(t: &str) -> CompletionResponse {
3559 CompletionResponse {
3560 content: vec![ContentBlock::Text { text: t.into() }],
3561 stop_reason: StopReason::EndTurn,
3562 reasoning: None,
3563 usage: TokenUsage::default(),
3564 model: None,
3565 }
3566 }
3567 let provider = Arc::new(MockProvider::new(vec![text("real answer")]));
3568 let interrupt = InterruptHandle::new();
3569 interrupt.interrupt();
3570 let inputs = Arc::new(std::sync::Mutex::new(vec![
3571 Some("follow up".to_string()),
3572 None,
3573 ]));
3574 let on_input: Arc<crate::agent::OnInput> = {
3575 let inputs = inputs.clone();
3576 Arc::new(move || {
3577 let inputs = inputs.clone();
3578 Box::pin(async move {
3579 let mut g = inputs.lock().expect("lock");
3580 if g.is_empty() { None } else { g.remove(0) }
3581 })
3582 })
3583 };
3584 let mut orch = Orchestrator::builder(provider)
3585 .sub_agent("a", "Agent A", "prompt a")
3586 .sub_agent("b", "Agent B", "prompt b")
3587 .on_input(on_input)
3588 .interrupt(interrupt)
3589 .build()
3590 .unwrap();
3591 let output = orch.run("start").await.unwrap();
3592 assert_eq!(output.result, "real answer");
3593 }
3594
3595 #[test]
3596 fn sequential_dispatch_disables_squads_in_prompt() {
3597 let tools: Vec<String> = vec![];
3598 let agents: Vec<(&str, &str, &[String])> = vec![
3599 ("a", "Agent A", tools.as_slice()),
3600 ("b", "Agent B", tools.as_slice()),
3601 ];
3602 let prompt = build_system_prompt(&agents, false, DispatchMode::Sequential);
3604 assert!(
3605 !prompt.contains("form_squad"),
3606 "sequential prompt should not mention form_squad: {prompt}"
3607 );
3608 }
3609
3610 #[test]
3611 fn delegate_tool_schema_includes_agents() {
3612 let tools = vec!["web_search".to_string()];
3613 let agents: Vec<(&str, &str, &[String])> =
3614 vec![("researcher", "Research", tools.as_slice())];
3615 let def = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3616 assert_eq!(def.name, "delegate_task");
3617 assert!(def.description.contains("researcher"));
3618 assert!(
3619 def.description.contains("web_search"),
3620 "delegate tool description should contain tool names: {}",
3621 def.description
3622 );
3623 assert!(
3624 def.description.contains("isolation"),
3625 "delegate tool description should mention isolation: {}",
3626 def.description
3627 );
3628 assert!(
3629 def.description.contains("self-contained"),
3630 "delegate tool description should mention self-contained tasks: {}",
3631 def.description
3632 );
3633 }
3634
3635 #[test]
3636 fn delegate_tool_definition_includes_agents() {
3637 let agents: Vec<(&str, &str, &[String])> = vec![("researcher", "Research", &[])];
3638 let cached_definition = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3639
3640 let tool = DelegateTaskTool {
3641 shared_provider: Arc::new(BoxedProvider::new(MockProvider::new(vec![]))),
3642 sub_agents: vec![SubAgentDef {
3643 name: "researcher".into(),
3644 description: "Research".into(),
3645 system_prompt: "prompt".into(),
3646 tools: vec![],
3647 context_strategy: None,
3648 summarize_threshold: None,
3649 tool_timeout: None,
3650 max_tool_output_bytes: None,
3651 max_turns: None,
3652 max_tokens: None,
3653 response_schema: None,
3654 run_timeout: None,
3655 guardrails: vec![],
3656 provider_override: None,
3657 reasoning_effort: None,
3658 enable_reflection: None,
3659 tool_output_compression_threshold: None,
3660 max_tools_per_turn: None,
3661 tool_profile: None,
3662 max_identical_tool_calls: None,
3663 max_fuzzy_identical_tool_calls: None,
3664 max_tool_calls_per_turn: None,
3665 session_prune_config: None,
3666 enable_recursive_summarization: None,
3667 reflection_threshold: None,
3668 consolidate_on_exit: None,
3669 workspace: None,
3670 max_total_tokens: None,
3671 audit_trail: None,
3672 audit_user_id: None,
3673 audit_tenant_id: None,
3674 audit_delegation_chain: Vec::new(),
3675 context: Default::default(),
3676 }],
3677 shared_memory: None,
3678 memory_namespace_prefix: None,
3679 blackboard: None,
3680 knowledge_base: None,
3681 max_turns: 10,
3682 max_tokens: 4096,
3683 permission_rules: crate::agent::permission::PermissionRuleset::default(),
3684 accumulated_tokens: Arc::new(Mutex::new(TokenUsage::default())),
3685 cached_definition,
3686 on_event: None,
3687 lsp_manager: None,
3688 observability_mode: crate::ObservabilityMode::Production,
3689 allow_shared_write: true,
3690 tenant_tracker: None,
3691 guardrails: vec![],
3692 on_approval: None,
3693 learned_permissions: None,
3694 fanout_refused: std::sync::Mutex::new(None),
3695 };
3696
3697 let def = tool.definition();
3698 assert_eq!(def.name, "delegate_task");
3699 assert!(def.description.contains("researcher"));
3700 assert!(
3701 def.description.contains("tools"),
3702 "delegate tool description should contain 'tools' key: {}",
3703 def.description
3704 );
3705 }
3706
3707 #[test]
3708 fn build_errors_on_duplicate_sub_agent_names() {
3709 let provider = Arc::new(MockProvider::new(vec![]));
3710 let result = Orchestrator::builder(provider)
3711 .sub_agent("researcher", "Research 1", "prompt1")
3712 .sub_agent("researcher", "Research 2", "prompt2")
3713 .build();
3714 assert!(result.is_err());
3715 let err = result.err().unwrap();
3716 assert!(
3717 err.to_string()
3718 .contains("duplicate sub-agent name: 'researcher'"),
3719 "error: {err}"
3720 );
3721 }
3722
3723 #[tokio::test]
3724 async fn orchestrator_direct_response_no_delegation() {
3725 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
3726 content: vec![ContentBlock::Text {
3727 text: "Simple answer.".into(),
3728 }],
3729 stop_reason: StopReason::EndTurn,
3730 reasoning: None,
3731 usage: TokenUsage {
3732 input_tokens: 10,
3733 output_tokens: 5,
3734 ..Default::default()
3735 },
3736 model: None,
3737 }]));
3738
3739 let mut orch = Orchestrator::builder(provider)
3740 .sub_agent("researcher", "Research", "prompt")
3741 .build()
3742 .unwrap();
3743
3744 let output = orch.run("simple question").await.unwrap();
3745 assert_eq!(output.result, "Simple answer.");
3746 assert_eq!(output.tool_calls_made, 0);
3747 }
3748
3749 #[tokio::test]
3750 async fn orchestrator_delegates_and_synthesizes() {
3751 let provider = Arc::new(MockProvider::new(vec![
3755 CompletionResponse {
3757 content: vec![ContentBlock::ToolUse {
3758 id: "call-1".into(),
3759 name: "delegate_task".into(),
3760 input: json!({
3761 "tasks": [
3762 {"agent": "researcher", "task": "Research Rust"},
3763 {"agent": "analyst", "task": "Analyze findings"}
3764 ]
3765 }),
3766 }],
3767 stop_reason: StopReason::ToolUse,
3768 reasoning: None,
3769 usage: TokenUsage {
3770 input_tokens: 50,
3771 output_tokens: 20,
3772 ..Default::default()
3773 },
3774 model: None,
3775 },
3776 CompletionResponse {
3778 content: vec![ContentBlock::Text {
3779 text: "Rust is fast and safe.".into(),
3780 }],
3781 stop_reason: StopReason::EndTurn,
3782 reasoning: None,
3783 usage: TokenUsage {
3784 input_tokens: 10,
3785 output_tokens: 8,
3786 ..Default::default()
3787 },
3788 model: None,
3789 },
3790 CompletionResponse {
3792 content: vec![ContentBlock::Text {
3793 text: "Strengths: memory safety, performance.".into(),
3794 }],
3795 stop_reason: StopReason::EndTurn,
3796 reasoning: None,
3797 usage: TokenUsage {
3798 input_tokens: 12,
3799 output_tokens: 10,
3800 ..Default::default()
3801 },
3802 model: None,
3803 },
3804 CompletionResponse {
3806 content: vec![ContentBlock::Text {
3807 text: "Based on research: Rust is excellent.".into(),
3808 }],
3809 stop_reason: StopReason::EndTurn,
3810 reasoning: None,
3811 usage: TokenUsage {
3812 input_tokens: 80,
3813 output_tokens: 30,
3814 ..Default::default()
3815 },
3816 model: None,
3817 },
3818 ]));
3819
3820 let mut orch = Orchestrator::builder(provider)
3821 .sub_agent("researcher", "Research specialist", "You research.")
3822 .sub_agent("analyst", "Analysis expert", "You analyze.")
3823 .build()
3824 .unwrap();
3825
3826 let output = orch.run("Analyze Rust").await.unwrap();
3827 assert_eq!(output.result, "Based on research: Rust is excellent.");
3828 assert_eq!(output.tool_calls_made, 1); assert_eq!(output.tokens_used.input_tokens, 50 + 80 + 10 + 12);
3831 assert_eq!(output.tokens_used.output_tokens, 20 + 30 + 8 + 10);
3832 }
3833
3834 #[tokio::test]
3835 async fn orchestrator_handles_unknown_agent_gracefully() {
3836 let provider = Arc::new(MockProvider::new(vec![
3838 CompletionResponse {
3839 content: vec![ContentBlock::ToolUse {
3840 id: "call-1".into(),
3841 name: "delegate_task".into(),
3842 input: json!({
3843 "tasks": [{"agent": "nonexistent", "task": "do stuff"}]
3844 }),
3845 }],
3846 stop_reason: StopReason::ToolUse,
3847 reasoning: None,
3848 usage: TokenUsage::default(),
3849 model: None,
3850 },
3851 CompletionResponse {
3853 content: vec![ContentBlock::Text {
3854 text: "No such agent available.".into(),
3855 }],
3856 stop_reason: StopReason::EndTurn,
3857 reasoning: None,
3858 usage: TokenUsage::default(),
3859 model: None,
3860 },
3861 ]));
3862
3863 let mut orch = Orchestrator::builder(provider)
3864 .sub_agent("researcher", "Research", "prompt")
3865 .build()
3866 .unwrap();
3867
3868 let output = orch.run("delegate to unknown").await.unwrap();
3869 assert_eq!(output.result, "No such agent available.");
3870 }
3871
3872 #[tokio::test]
3873 async fn orchestrator_handles_invalid_tool_name() {
3874 let provider = Arc::new(MockProvider::new(vec![
3875 CompletionResponse {
3876 content: vec![ContentBlock::ToolUse {
3877 id: "call-1".into(),
3878 name: "wrong_tool".into(),
3879 input: json!({}),
3880 }],
3881 stop_reason: StopReason::ToolUse,
3882 reasoning: None,
3883 usage: TokenUsage::default(),
3884 model: None,
3885 },
3886 CompletionResponse {
3887 content: vec![ContentBlock::Text {
3888 text: "Sorry, let me respond directly.".into(),
3889 }],
3890 stop_reason: StopReason::EndTurn,
3891 reasoning: None,
3892 usage: TokenUsage::default(),
3893 model: None,
3894 },
3895 ]));
3896
3897 let mut orch = Orchestrator::builder(provider)
3898 .sub_agent("researcher", "Research", "prompt")
3899 .build()
3900 .unwrap();
3901
3902 let output = orch.run("do something").await.unwrap();
3903 assert_eq!(output.result, "Sorry, let me respond directly.");
3904 }
3905
3906 #[tokio::test]
3907 async fn orchestrator_handles_empty_delegate_tasks() {
3908 let provider = Arc::new(MockProvider::new(vec![
3909 CompletionResponse {
3911 content: vec![ContentBlock::ToolUse {
3912 id: "call-1".into(),
3913 name: "delegate_task".into(),
3914 input: json!({"tasks": []}),
3915 }],
3916 stop_reason: StopReason::ToolUse,
3917 reasoning: None,
3918 usage: TokenUsage::default(),
3919 model: None,
3920 },
3921 CompletionResponse {
3923 content: vec![ContentBlock::Text {
3924 text: "Let me try again properly.".into(),
3925 }],
3926 stop_reason: StopReason::EndTurn,
3927 reasoning: None,
3928 usage: TokenUsage::default(),
3929 model: None,
3930 },
3931 ]));
3932
3933 let mut orch = Orchestrator::builder(provider)
3934 .sub_agent("researcher", "Research", "prompt")
3935 .build()
3936 .unwrap();
3937
3938 let output = orch.run("do something").await.unwrap();
3939 assert_eq!(output.result, "Let me try again properly.");
3940 }
3941
3942 #[tokio::test]
3943 async fn orchestrator_handles_missing_tasks_field() {
3944 let provider = Arc::new(MockProvider::new(vec![
3945 CompletionResponse {
3947 content: vec![ContentBlock::ToolUse {
3948 id: "call-1".into(),
3949 name: "delegate_task".into(),
3950 input: json!({}),
3951 }],
3952 stop_reason: StopReason::ToolUse,
3953 reasoning: None,
3954 usage: TokenUsage::default(),
3955 model: None,
3956 },
3957 CompletionResponse {
3959 content: vec![ContentBlock::Text {
3960 text: "I need to format correctly.".into(),
3961 }],
3962 stop_reason: StopReason::EndTurn,
3963 reasoning: None,
3964 usage: TokenUsage::default(),
3965 model: None,
3966 },
3967 ]));
3968
3969 let mut orch = Orchestrator::builder(provider)
3970 .sub_agent("researcher", "Research", "prompt")
3971 .build()
3972 .unwrap();
3973
3974 let output = orch.run("do something").await.unwrap();
3975 assert_eq!(output.result, "I need to format correctly.");
3976 }
3977
3978 #[tokio::test]
3979 async fn blackboard_populated_after_delegation() {
3980 use crate::agent::blackboard::InMemoryBlackboard;
3981
3982 let bb = Arc::new(InMemoryBlackboard::new());
3983
3984 let provider = Arc::new(MockProvider::new(vec![
3985 CompletionResponse {
3987 content: vec![ContentBlock::ToolUse {
3988 id: "call-1".into(),
3989 name: "delegate_task".into(),
3990 input: json!({
3991 "tasks": [{"agent": "researcher", "task": "Find info"}]
3992 }),
3993 }],
3994 stop_reason: StopReason::ToolUse,
3995 reasoning: None,
3996 usage: TokenUsage::default(),
3997 model: None,
3998 },
3999 CompletionResponse {
4001 content: vec![ContentBlock::Text {
4002 text: "Research result here.".into(),
4003 }],
4004 stop_reason: StopReason::EndTurn,
4005 reasoning: None,
4006 usage: TokenUsage::default(),
4007 model: None,
4008 },
4009 CompletionResponse {
4011 content: vec![ContentBlock::Text {
4012 text: "Done.".into(),
4013 }],
4014 stop_reason: StopReason::EndTurn,
4015 reasoning: None,
4016 usage: TokenUsage::default(),
4017 model: None,
4018 },
4019 ]));
4020
4021 let mut orch = Orchestrator::builder(provider)
4022 .sub_agent("researcher", "Research specialist", "You research.")
4023 .blackboard(bb.clone())
4024 .build()
4025 .unwrap();
4026
4027 orch.run("research something").await.unwrap();
4028
4029 let val: Option<serde_json::Value> = bb.read("agent:researcher").await.unwrap();
4031 assert!(val.is_some(), "blackboard should have agent:researcher key");
4032 assert_eq!(
4033 val.unwrap(),
4034 serde_json::Value::String("Research result here.".into())
4035 );
4036 }
4037
4038 #[tokio::test]
4039 async fn sub_agents_receive_blackboard_tools() {
4040 use crate::agent::blackboard::InMemoryBlackboard;
4041 use crate::llm::types::CompletionRequest;
4042
4043 struct ToolTrackingProvider {
4045 responses: Mutex<Vec<CompletionResponse>>,
4046 tool_names_seen: Mutex<Vec<Vec<String>>>,
4047 }
4048
4049 impl LlmProvider for ToolTrackingProvider {
4050 async fn complete(
4051 &self,
4052 request: CompletionRequest,
4053 ) -> Result<CompletionResponse, Error> {
4054 let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
4055 self.tool_names_seen.lock().expect("lock").push(names);
4056
4057 let mut responses = self.responses.lock().expect("lock");
4058 if responses.is_empty() {
4059 return Err(Error::Agent("no more mock responses".into()));
4060 }
4061 Ok(responses.remove(0))
4062 }
4063 }
4064
4065 let bb = Arc::new(InMemoryBlackboard::new());
4066
4067 let provider = Arc::new(ToolTrackingProvider {
4068 responses: Mutex::new(vec![
4069 CompletionResponse {
4071 content: vec![ContentBlock::ToolUse {
4072 id: "call-1".into(),
4073 name: "delegate_task".into(),
4074 input: json!({
4075 "tasks": [{"agent": "worker", "task": "do work"}]
4076 }),
4077 }],
4078 stop_reason: StopReason::ToolUse,
4079 reasoning: None,
4080 usage: TokenUsage::default(),
4081 model: None,
4082 },
4083 CompletionResponse {
4085 content: vec![ContentBlock::Text {
4086 text: "Work done.".into(),
4087 }],
4088 stop_reason: StopReason::EndTurn,
4089 reasoning: None,
4090 usage: TokenUsage::default(),
4091 model: None,
4092 },
4093 CompletionResponse {
4095 content: vec![ContentBlock::Text {
4096 text: "All done.".into(),
4097 }],
4098 stop_reason: StopReason::EndTurn,
4099 reasoning: None,
4100 usage: TokenUsage::default(),
4101 model: None,
4102 },
4103 ]),
4104 tool_names_seen: Mutex::new(vec![]),
4105 });
4106
4107 let mut orch = Orchestrator::builder(provider.clone())
4108 .sub_agent("worker", "Worker agent", "You work.")
4109 .blackboard(bb)
4110 .build()
4111 .unwrap();
4112
4113 orch.run("do work").await.unwrap();
4114
4115 let all_tool_names = provider.tool_names_seen.lock().expect("lock");
4117 assert!(
4118 all_tool_names.len() >= 2,
4119 "expected at least 2 LLM calls, got {}",
4120 all_tool_names.len()
4121 );
4122 let sub_agent_tools = &all_tool_names[1];
4123 assert!(
4124 sub_agent_tools.contains(&"blackboard_read".to_string()),
4125 "sub-agent should have blackboard_read tool, got: {sub_agent_tools:?}"
4126 );
4127 assert!(
4128 sub_agent_tools.contains(&"blackboard_write".to_string()),
4129 "sub-agent should have blackboard_write tool, got: {sub_agent_tools:?}"
4130 );
4131 assert!(
4132 sub_agent_tools.contains(&"blackboard_list".to_string()),
4133 "sub-agent should have blackboard_list tool, got: {sub_agent_tools:?}"
4134 );
4135 }
4136
4137 #[test]
4138 fn blackboard_builder_method_works() {
4139 use crate::agent::blackboard::InMemoryBlackboard;
4140
4141 let bb = Arc::new(InMemoryBlackboard::new());
4142 let provider = Arc::new(MockProvider::new(vec![]));
4143
4144 let result = Orchestrator::builder(provider)
4146 .sub_agent("agent1", "Agent one", "You are agent 1.")
4147 .blackboard(bb)
4148 .build();
4149
4150 assert!(result.is_ok());
4151 }
4152
4153 #[test]
4154 fn knowledge_builder_method_works() {
4155 use crate::knowledge::in_memory::InMemoryKnowledgeBase;
4156
4157 let kb: Arc<dyn KnowledgeBase> = Arc::new(InMemoryKnowledgeBase::new());
4158 let provider = Arc::new(MockProvider::new(vec![]));
4159
4160 let result = Orchestrator::builder(provider)
4161 .sub_agent("agent1", "Agent one", "You are agent 1.")
4162 .knowledge(kb)
4163 .build();
4164
4165 assert!(result.is_ok());
4166 }
4167
4168 #[tokio::test]
4169 async fn sub_agents_receive_knowledge_tools() {
4170 use crate::knowledge::in_memory::InMemoryKnowledgeBase;
4171 use crate::llm::types::CompletionRequest;
4172
4173 struct ToolTrackingProvider {
4174 responses: Mutex<Vec<CompletionResponse>>,
4175 tool_names_seen: Mutex<Vec<Vec<String>>>,
4176 }
4177
4178 impl LlmProvider for ToolTrackingProvider {
4179 async fn complete(
4180 &self,
4181 request: CompletionRequest,
4182 ) -> Result<CompletionResponse, Error> {
4183 let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
4184 self.tool_names_seen.lock().expect("lock").push(names);
4185
4186 let mut responses = self.responses.lock().expect("lock");
4187 if responses.is_empty() {
4188 return Err(Error::Agent("no more mock responses".into()));
4189 }
4190 Ok(responses.remove(0))
4191 }
4192 }
4193
4194 let kb: Arc<dyn KnowledgeBase> = Arc::new(InMemoryKnowledgeBase::new());
4195
4196 let provider = Arc::new(ToolTrackingProvider {
4197 responses: Mutex::new(vec![
4198 CompletionResponse {
4200 content: vec![ContentBlock::ToolUse {
4201 id: "call-1".into(),
4202 name: "delegate_task".into(),
4203 input: json!({
4204 "tasks": [{"agent": "worker", "task": "do work"}]
4205 }),
4206 }],
4207 stop_reason: StopReason::ToolUse,
4208 reasoning: None,
4209 usage: TokenUsage::default(),
4210 model: None,
4211 },
4212 CompletionResponse {
4214 content: vec![ContentBlock::Text {
4215 text: "Work done.".into(),
4216 }],
4217 stop_reason: StopReason::EndTurn,
4218 reasoning: None,
4219 usage: TokenUsage::default(),
4220 model: None,
4221 },
4222 CompletionResponse {
4224 content: vec![ContentBlock::Text {
4225 text: "All done.".into(),
4226 }],
4227 stop_reason: StopReason::EndTurn,
4228 reasoning: None,
4229 usage: TokenUsage::default(),
4230 model: None,
4231 },
4232 ]),
4233 tool_names_seen: Mutex::new(vec![]),
4234 });
4235
4236 let mut orch = Orchestrator::builder(provider.clone())
4237 .sub_agent("worker", "Worker agent", "You work.")
4238 .knowledge(kb)
4239 .build()
4240 .unwrap();
4241
4242 orch.run("do work").await.unwrap();
4243
4244 let all_tool_names = provider.tool_names_seen.lock().expect("lock");
4245 assert!(
4246 all_tool_names.len() >= 2,
4247 "expected at least 2 LLM calls, got {}",
4248 all_tool_names.len()
4249 );
4250 let sub_agent_tools = &all_tool_names[1];
4251 assert!(
4252 sub_agent_tools.contains(&"knowledge_search".to_string()),
4253 "sub-agent should have knowledge_search tool, got: {sub_agent_tools:?}"
4254 );
4255 }
4256
4257 #[tokio::test]
4258 async fn orchestrator_accumulates_cache_tokens_through_delegation() {
4259 let provider = Arc::new(MockProvider::new(vec![
4260 CompletionResponse {
4262 content: vec![ContentBlock::ToolUse {
4263 id: "call-1".into(),
4264 name: "delegate_task".into(),
4265 input: json!({
4266 "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4267 }),
4268 }],
4269 stop_reason: StopReason::ToolUse,
4270 reasoning: None,
4271 usage: TokenUsage {
4272 input_tokens: 50,
4273 output_tokens: 20,
4274 cache_creation_input_tokens: 100,
4275 cache_read_input_tokens: 0,
4276 reasoning_tokens: 0,
4277 },
4278 model: None,
4279 },
4280 CompletionResponse {
4282 content: vec![ContentBlock::Text {
4283 text: "Rust is fast.".into(),
4284 }],
4285 stop_reason: StopReason::EndTurn,
4286 reasoning: None,
4287 usage: TokenUsage {
4288 input_tokens: 10,
4289 output_tokens: 8,
4290 cache_creation_input_tokens: 0,
4291 cache_read_input_tokens: 30,
4292 reasoning_tokens: 0,
4293 },
4294 model: None,
4295 },
4296 CompletionResponse {
4298 content: vec![ContentBlock::Text {
4299 text: "Rust is excellent.".into(),
4300 }],
4301 stop_reason: StopReason::EndTurn,
4302 reasoning: None,
4303 usage: TokenUsage {
4304 input_tokens: 80,
4305 output_tokens: 30,
4306 cache_creation_input_tokens: 0,
4307 cache_read_input_tokens: 90,
4308 reasoning_tokens: 0,
4309 },
4310 model: None,
4311 },
4312 ]));
4313
4314 let mut orch = Orchestrator::builder(provider)
4315 .sub_agent("researcher", "Research specialist", "You research.")
4316 .build()
4317 .unwrap();
4318
4319 let output = orch.run("Analyze Rust").await.unwrap();
4320 assert_eq!(output.tokens_used.input_tokens, 50 + 80 + 10);
4323 assert_eq!(output.tokens_used.output_tokens, 20 + 30 + 8);
4324 assert_eq!(output.tokens_used.cache_creation_input_tokens, 100);
4325 assert_eq!(output.tokens_used.cache_read_input_tokens, 90 + 30);
4326 }
4327
4328 #[tokio::test]
4329 async fn orchestrator_error_includes_sub_agent_tokens() {
4330 let provider = Arc::new(MockProvider::new(vec![
4334 CompletionResponse {
4336 content: vec![ContentBlock::ToolUse {
4337 id: "call-1".into(),
4338 name: "delegate_task".into(),
4339 input: json!({
4340 "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4341 }),
4342 }],
4343 stop_reason: StopReason::ToolUse,
4344 reasoning: None,
4345 usage: TokenUsage {
4346 input_tokens: 50,
4347 output_tokens: 20,
4348 ..Default::default()
4349 },
4350 model: None,
4351 },
4352 CompletionResponse {
4354 content: vec![ContentBlock::Text {
4355 text: "Rust is fast.".into(),
4356 }],
4357 stop_reason: StopReason::EndTurn,
4358 reasoning: None,
4359 usage: TokenUsage {
4360 input_tokens: 15,
4361 output_tokens: 10,
4362 ..Default::default()
4363 },
4364 model: None,
4365 },
4366 CompletionResponse {
4368 content: vec![ContentBlock::ToolUse {
4369 id: "call-2".into(),
4370 name: "delegate_task".into(),
4371 input: json!({
4372 "tasks": [{"agent": "researcher", "task": "More research"}]
4373 }),
4374 }],
4375 stop_reason: StopReason::ToolUse,
4376 reasoning: None,
4377 usage: TokenUsage {
4378 input_tokens: 80,
4379 output_tokens: 25,
4380 ..Default::default()
4381 },
4382 model: None,
4383 },
4384 CompletionResponse {
4386 content: vec![ContentBlock::Text {
4387 text: "More info.".into(),
4388 }],
4389 stop_reason: StopReason::EndTurn,
4390 reasoning: None,
4391 usage: TokenUsage {
4392 input_tokens: 12,
4393 output_tokens: 8,
4394 ..Default::default()
4395 },
4396 model: None,
4397 },
4398 ]));
4399
4400 let mut orch = Orchestrator::builder(provider)
4401 .sub_agent("researcher", "Research", "prompt")
4402 .max_turns(2)
4403 .build()
4404 .unwrap();
4405
4406 let err = orch.run("research deeply").await.unwrap_err();
4407
4408 match &err {
4410 Error::WithPartialUsage { source, .. } => {
4411 assert!(
4412 matches!(**source, Error::MaxTurnsExceeded(2)),
4413 "inner error should be MaxTurnsExceeded(2), got: {source}"
4414 );
4415 }
4416 other => panic!("expected WithPartialUsage, got: {other}"),
4417 }
4418
4419 let usage = err.partial_usage();
4420 assert_eq!(
4422 usage.input_tokens,
4423 50 + 80 + 15 + 12,
4424 "input tokens: orchestrator(50+80) + sub-agent(15+12)"
4425 );
4426 assert_eq!(
4427 usage.output_tokens,
4428 20 + 25 + 10 + 8,
4429 "output tokens: orchestrator(20+25) + sub-agent(10+8)"
4430 );
4431 }
4432
4433 #[tokio::test]
4434 async fn on_event_emits_sub_agent_events() {
4435 use crate::agent::events::AgentEvent;
4436
4437 let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
4438 Arc::new(std::sync::Mutex::new(vec![]));
4439 let events_clone = events.clone();
4440
4441 let provider = Arc::new(MockProvider::new(vec![
4442 CompletionResponse {
4444 content: vec![ContentBlock::ToolUse {
4445 id: "call-1".into(),
4446 name: "delegate_task".into(),
4447 input: json!({
4448 "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4449 }),
4450 }],
4451 stop_reason: StopReason::ToolUse,
4452 reasoning: None,
4453 usage: TokenUsage::default(),
4454 model: None,
4455 },
4456 CompletionResponse {
4458 content: vec![ContentBlock::Text {
4459 text: "Rust is fast.".into(),
4460 }],
4461 stop_reason: StopReason::EndTurn,
4462 reasoning: None,
4463 usage: TokenUsage {
4464 input_tokens: 10,
4465 output_tokens: 5,
4466 ..Default::default()
4467 },
4468 model: None,
4469 },
4470 CompletionResponse {
4472 content: vec![ContentBlock::Text {
4473 text: "Summary: Rust is fast.".into(),
4474 }],
4475 stop_reason: StopReason::EndTurn,
4476 reasoning: None,
4477 usage: TokenUsage::default(),
4478 model: None,
4479 },
4480 ]));
4481
4482 let mut orch = Orchestrator::builder(provider)
4483 .sub_agent("researcher", "Research", "prompt")
4484 .on_event(Arc::new(move |e| {
4485 events_clone.lock().unwrap().push(e);
4486 }))
4487 .build()
4488 .unwrap();
4489
4490 orch.run("research task").await.unwrap();
4491
4492 let events = events.lock().unwrap();
4493
4494 let dispatched: Vec<_> = events
4495 .iter()
4496 .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
4497 .collect();
4498 assert_eq!(dispatched.len(), 1, "expected 1 SubAgentsDispatched");
4499 match &dispatched[0] {
4500 AgentEvent::SubAgentsDispatched { agent, agents } => {
4501 assert_eq!(agent, "orchestrator");
4502 assert_eq!(agents, &["researcher"]);
4503 }
4504 _ => unreachable!(),
4505 }
4506
4507 let completed: Vec<_> = events
4508 .iter()
4509 .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
4510 .collect();
4511 assert_eq!(completed.len(), 1, "expected 1 SubAgentCompleted");
4512 match &completed[0] {
4513 AgentEvent::SubAgentCompleted {
4514 agent,
4515 success,
4516 usage,
4517 } => {
4518 assert_eq!(agent, "researcher");
4519 assert!(success);
4520 assert_eq!(usage.input_tokens, 10);
4521 }
4522 _ => unreachable!(),
4523 }
4524 }
4525
4526 #[tokio::test]
4535 async fn sub_agent_text_does_not_reach_on_text() {
4536 struct StreamingMock {
4540 responses: Mutex<Vec<CompletionResponse>>,
4541 }
4542 impl LlmProvider for StreamingMock {
4543 async fn complete(
4544 &self,
4545 _request: CompletionRequest,
4546 ) -> Result<CompletionResponse, Error> {
4547 let mut r = self.responses.lock().expect("mock lock");
4548 if r.is_empty() {
4549 return Err(Error::Agent("no more mock responses".into()));
4550 }
4551 Ok(r.remove(0))
4552 }
4553 async fn stream_complete(
4554 &self,
4555 request: CompletionRequest,
4556 on_text: &crate::llm::OnText,
4557 ) -> Result<CompletionResponse, Error> {
4558 let resp = self.complete(request).await?;
4559 for block in &resp.content {
4560 if let ContentBlock::Text { text } = block {
4561 on_text(text);
4562 }
4563 }
4564 Ok(resp)
4565 }
4566 fn model_name(&self) -> Option<&str> {
4567 Some("stream-mock")
4568 }
4569 }
4570
4571 let texts: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
4572 let texts_clone = texts.clone();
4573
4574 let provider = Arc::new(StreamingMock {
4575 responses: Mutex::new(vec![
4576 CompletionResponse {
4578 content: vec![ContentBlock::ToolUse {
4579 id: "call-1".into(),
4580 name: "delegate_task".into(),
4581 input: json!({
4582 "tasks": [{"agent": "researcher", "task": "Research"}]
4583 }),
4584 }],
4585 stop_reason: StopReason::ToolUse,
4586 reasoning: None,
4587 usage: TokenUsage::default(),
4588 model: None,
4589 },
4590 CompletionResponse {
4592 content: vec![ContentBlock::Text {
4593 text: "SUBAGENT_SECRET".into(),
4594 }],
4595 stop_reason: StopReason::EndTurn,
4596 reasoning: None,
4597 usage: TokenUsage::default(),
4598 model: None,
4599 },
4600 CompletionResponse {
4602 content: vec![ContentBlock::Text {
4603 text: "ORCH_SYNTHESIS".into(),
4604 }],
4605 stop_reason: StopReason::EndTurn,
4606 reasoning: None,
4607 usage: TokenUsage::default(),
4608 model: None,
4609 },
4610 ]),
4611 });
4612
4613 let mut orch = Orchestrator::builder(provider)
4614 .sub_agent("researcher", "Research", "prompt")
4615 .on_text(Arc::new(move |s: &str| {
4616 texts_clone.lock().unwrap().push(s.to_string());
4617 }))
4618 .build()
4619 .unwrap();
4620
4621 orch.run("task").await.unwrap();
4622
4623 let got = texts.lock().unwrap().join("");
4624 assert!(
4625 got.contains("ORCH_SYNTHESIS"),
4626 "the orchestrator's own synthesis must still stream (got: {got:?})"
4627 );
4628 assert!(
4629 !got.contains("SUBAGENT_SECRET"),
4630 "a delegated sub-agent's text must NOT reach on_text — it crisscrosses \
4631 with other parallel sub-agents (got: {got:?})"
4632 );
4633 }
4634
4635 fn dtask(agent: &str, task: &str) -> DelegatedTask {
4636 DelegatedTask {
4637 agent: agent.into(),
4638 task: task.into(),
4639 }
4640 }
4641
4642 #[test]
4643 fn same_agent_fanout_fires_on_pile_up_only() {
4644 let four_workers = vec![
4649 dtask("worker", "create the dir structure in /tmp/crm_project"),
4650 dtask("worker", "create Cargo.toml"),
4651 dtask("worker", "create src/models.rs"),
4652 dtask("worker", "create src/config.rs"),
4653 ];
4654 assert_eq!(
4655 same_agent_fanout(&four_workers, SAME_AGENT_FANOUT_NUDGE_AT),
4656 Some(("worker".to_string(), 4))
4657 );
4658 assert_eq!(
4660 same_agent_fanout(&four_workers[..3], SAME_AGENT_FANOUT_NUDGE_AT),
4661 Some(("worker".to_string(), 3))
4662 );
4663 assert_eq!(
4665 same_agent_fanout(&four_workers[..2], SAME_AGENT_FANOUT_NUDGE_AT),
4666 None
4667 );
4668 assert_eq!(
4670 same_agent_fanout(
4671 &[
4672 dtask("worker", "build X"),
4673 dtask("researcher", "investigate Y")
4674 ],
4675 SAME_AGENT_FANOUT_NUDGE_AT
4676 ),
4677 None
4678 );
4679 assert_eq!(
4681 same_agent_fanout(
4682 &[
4683 dtask("worker", "a"),
4684 dtask("worker", "b"),
4685 dtask("worker", "c"),
4686 dtask("researcher", "d"),
4687 ],
4688 SAME_AGENT_FANOUT_NUDGE_AT
4689 ),
4690 Some(("worker".to_string(), 3))
4691 );
4692 assert_eq!(
4695 same_agent_fanout(
4696 &[
4697 dtask("worker", "a"),
4698 dtask("worker", "b"),
4699 dtask("researcher", "c"),
4700 dtask("researcher", "d"),
4701 ],
4702 SAME_AGENT_FANOUT_NUDGE_AT
4703 ),
4704 None
4705 );
4706 }
4707
4708 #[test]
4709 fn batch_fingerprint_is_stable_and_discriminating() {
4710 let a = vec![dtask("worker", "alpha"), dtask("worker", "beta")];
4711 let a2 = vec![dtask("worker", "alpha"), dtask("worker", "beta")];
4712 let b = vec![dtask("worker", "alpha"), dtask("worker", "GAMMA")];
4713 assert_eq!(batch_fingerprint(&a), batch_fingerprint(&a2));
4715 assert_ne!(batch_fingerprint(&a), batch_fingerprint(&b));
4718 }
4719
4720 #[tokio::test]
4725 async fn same_agent_fanout_refused_once_then_dispatches_on_retry() {
4726 use crate::agent::events::AgentEvent;
4727
4728 let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
4729 Arc::new(std::sync::Mutex::new(vec![]));
4730 let ev = events.clone();
4731
4732 let fanout = json!({"tasks": [
4733 {"agent": "worker", "task": "create /tmp/p/a.rs"},
4734 {"agent": "worker", "task": "create /tmp/p/b.rs"},
4735 {"agent": "worker", "task": "create /tmp/p/c.rs"}
4736 ]});
4737 let delegate = |input: serde_json::Value| CompletionResponse {
4738 content: vec![ContentBlock::ToolUse {
4739 id: "call-1".into(),
4740 name: "delegate_task".into(),
4741 input,
4742 }],
4743 stop_reason: StopReason::ToolUse,
4744 reasoning: None,
4745 usage: TokenUsage::default(),
4746 model: None,
4747 };
4748 let text = |t: &str| CompletionResponse {
4749 content: vec![ContentBlock::Text { text: t.into() }],
4750 stop_reason: StopReason::EndTurn,
4751 reasoning: None,
4752 usage: TokenUsage::default(),
4753 model: None,
4754 };
4755
4756 let provider = Arc::new(MockProvider::new(vec![
4757 delegate(fanout.clone()), delegate(fanout.clone()), text("done a"), text("done b"),
4761 text("done c"),
4762 text("synthesis"), ]));
4764
4765 let mut orch = Orchestrator::builder(provider)
4766 .sub_agent("worker", "General implementation agent", "prompt")
4767 .on_event(Arc::new(move |e| ev.lock().unwrap().push(e)))
4768 .build()
4769 .unwrap();
4770
4771 orch.run("build a small project").await.unwrap();
4772
4773 let events = events.lock().unwrap();
4774 let gate_ids: Vec<&str> = events
4775 .iter()
4776 .filter_map(|e| match e {
4777 AgentEvent::GateFired { gate, .. } => Some(gate.as_str()),
4778 _ => None,
4779 })
4780 .collect();
4781 assert!(
4782 gate_ids.contains(&"same_agent_fanout"),
4783 "the pile-up must be refused once with a GateFired nudge: {gate_ids:?}"
4784 );
4785 assert!(
4786 gate_ids.contains(&"same_agent_fanout_dispatched"),
4787 "the explicit retry must dispatch AND record retry-through: {gate_ids:?}"
4788 );
4789 let dispatched = events
4791 .iter()
4792 .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
4793 .count();
4794 assert_eq!(
4795 dispatched, 1,
4796 "first attempt refused (0 dispatch), retry dispatched (1) → exactly 1"
4797 );
4798 }
4799
4800 #[tokio::test]
4801 async fn sub_agent_receives_guardrails() {
4802 use crate::agent::guardrail::Guardrail;
4803 use crate::llm::types::CompletionRequest;
4804
4805 struct MarkerGuardrail;
4807 impl Guardrail for MarkerGuardrail {
4808 fn pre_llm(
4809 &self,
4810 request: &mut CompletionRequest,
4811 ) -> std::pin::Pin<
4812 Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
4813 > {
4814 request.system = format!("{} [GUARDRAIL_ACTIVE]", request.system);
4815 Box::pin(async { Ok(()) })
4816 }
4817 }
4818
4819 struct CapturingProvider {
4820 responses: Mutex<Vec<CompletionResponse>>,
4821 systems_seen: Mutex<Vec<String>>,
4822 }
4823
4824 impl LlmProvider for CapturingProvider {
4825 async fn complete(
4826 &self,
4827 request: CompletionRequest,
4828 ) -> Result<CompletionResponse, crate::error::Error> {
4829 self.systems_seen
4830 .lock()
4831 .unwrap()
4832 .push(request.system.clone());
4833 let mut responses = self.responses.lock().unwrap();
4834 if responses.is_empty() {
4835 return Err(crate::error::Error::Agent("no more responses".into()));
4836 }
4837 Ok(responses.remove(0))
4838 }
4839 }
4840
4841 let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
4842
4843 let provider = Arc::new(CapturingProvider {
4844 responses: Mutex::new(vec![
4845 CompletionResponse {
4847 content: vec![ContentBlock::ToolUse {
4848 id: "call-1".into(),
4849 name: "delegate_task".into(),
4850 input: json!({
4851 "tasks": [{"agent": "worker", "task": "do work"}]
4852 }),
4853 }],
4854 stop_reason: StopReason::ToolUse,
4855 reasoning: None,
4856 usage: TokenUsage::default(),
4857 model: None,
4858 },
4859 CompletionResponse {
4861 content: vec![ContentBlock::Text {
4862 text: "Work done.".into(),
4863 }],
4864 stop_reason: StopReason::EndTurn,
4865 reasoning: None,
4866 usage: TokenUsage::default(),
4867 model: None,
4868 },
4869 CompletionResponse {
4871 content: vec![ContentBlock::Text {
4872 text: "All done.".into(),
4873 }],
4874 stop_reason: StopReason::EndTurn,
4875 reasoning: None,
4876 usage: TokenUsage::default(),
4877 model: None,
4878 },
4879 ]),
4880 systems_seen: Mutex::new(vec![]),
4881 });
4882
4883 let mut orch = Orchestrator::builder(provider.clone())
4884 .sub_agent_full(SubAgentConfig {
4885 name: "worker".into(),
4886 description: "Worker agent".into(),
4887 system_prompt: "You work.".into(),
4888 tools: vec![],
4889 context_strategy: None,
4890 summarize_threshold: None,
4891 tool_timeout: None,
4892 max_tool_output_bytes: None,
4893 max_turns: None,
4894 max_tokens: None,
4895 response_schema: None,
4896 run_timeout: None,
4897 guardrails: vec![guardrail],
4898 provider: None,
4899 reasoning_effort: None,
4900 enable_reflection: None,
4901 tool_output_compression_threshold: None,
4902 max_tools_per_turn: None,
4903 tool_profile: None,
4904 max_identical_tool_calls: None,
4905 max_fuzzy_identical_tool_calls: None,
4906 max_tool_calls_per_turn: None,
4907 session_prune_config: None,
4908 enable_recursive_summarization: None,
4909 reflection_threshold: None,
4910 consolidate_on_exit: None,
4911 workspace: None,
4912 max_total_tokens: None,
4913 audit_trail: None,
4914 audit_user_id: None,
4915 audit_tenant_id: None,
4916 audit_delegation_chain: Vec::new(),
4917 context: Default::default(),
4918 })
4919 .build()
4920 .unwrap();
4921
4922 orch.run("do work").await.unwrap();
4923
4924 let systems = provider.systems_seen.lock().unwrap();
4926 assert!(
4927 systems.len() >= 2,
4928 "expected at least 2 LLM calls, got {}",
4929 systems.len()
4930 );
4931 assert!(
4933 systems[1].contains("[GUARDRAIL_ACTIVE]"),
4934 "sub-agent system prompt should contain guardrail marker: {}",
4935 systems[1]
4936 );
4937 assert!(
4939 !systems[0].contains("[GUARDRAIL_ACTIVE]"),
4940 "orchestrator system prompt should NOT contain guardrail marker: {}",
4941 systems[0]
4942 );
4943 }
4944
4945 #[tokio::test]
4951 async fn orchestrator_guardrails_propagate_to_delegated_sub_agents() {
4952 use crate::agent::guardrail::Guardrail;
4953 use crate::llm::types::CompletionRequest;
4954
4955 struct MarkerGuardrail;
4956 impl Guardrail for MarkerGuardrail {
4957 fn pre_llm(
4958 &self,
4959 request: &mut CompletionRequest,
4960 ) -> std::pin::Pin<
4961 Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
4962 > {
4963 request.system = format!("{} [ORCH_GUARD_ACTIVE]", request.system);
4964 Box::pin(async { Ok(()) })
4965 }
4966 }
4967
4968 struct CapturingProvider {
4969 responses: Mutex<Vec<CompletionResponse>>,
4970 systems_seen: Mutex<Vec<String>>,
4971 }
4972
4973 impl LlmProvider for CapturingProvider {
4974 async fn complete(
4975 &self,
4976 request: CompletionRequest,
4977 ) -> Result<CompletionResponse, crate::error::Error> {
4978 self.systems_seen
4979 .lock()
4980 .unwrap()
4981 .push(request.system.clone());
4982 let mut responses = self.responses.lock().unwrap();
4983 if responses.is_empty() {
4984 return Err(crate::error::Error::Agent("no more responses".into()));
4985 }
4986 Ok(responses.remove(0))
4987 }
4988 }
4989
4990 let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
4991
4992 let provider = Arc::new(CapturingProvider {
4993 responses: Mutex::new(vec![
4994 CompletionResponse {
4996 content: vec![ContentBlock::ToolUse {
4997 id: "call-1".into(),
4998 name: "delegate_task".into(),
4999 input: json!({
5000 "tasks": [{"agent": "worker", "task": "do work"}]
5001 }),
5002 }],
5003 stop_reason: StopReason::ToolUse,
5004 reasoning: None,
5005 usage: TokenUsage::default(),
5006 model: None,
5007 },
5008 CompletionResponse {
5010 content: vec![ContentBlock::Text {
5011 text: "Work done.".into(),
5012 }],
5013 stop_reason: StopReason::EndTurn,
5014 reasoning: None,
5015 usage: TokenUsage::default(),
5016 model: None,
5017 },
5018 CompletionResponse {
5020 content: vec![ContentBlock::Text {
5021 text: "Synthesized.".into(),
5022 }],
5023 stop_reason: StopReason::EndTurn,
5024 reasoning: None,
5025 usage: TokenUsage::default(),
5026 model: None,
5027 },
5028 ]),
5029 systems_seen: Mutex::new(vec![]),
5030 });
5031
5032 let mut orch = Orchestrator::builder(provider.clone())
5035 .guardrail(guardrail)
5036 .sub_agent_full(SubAgentConfig {
5037 name: "worker".into(),
5038 description: "Worker agent".into(),
5039 system_prompt: "You work.".into(),
5040 tools: vec![],
5041 context_strategy: None,
5042 summarize_threshold: None,
5043 tool_timeout: None,
5044 max_tool_output_bytes: None,
5045 max_turns: None,
5046 max_tokens: None,
5047 response_schema: None,
5048 run_timeout: None,
5049 guardrails: vec![],
5050 provider: None,
5051 reasoning_effort: None,
5052 enable_reflection: None,
5053 tool_output_compression_threshold: None,
5054 max_tools_per_turn: None,
5055 tool_profile: None,
5056 max_identical_tool_calls: None,
5057 max_fuzzy_identical_tool_calls: None,
5058 max_tool_calls_per_turn: None,
5059 session_prune_config: None,
5060 enable_recursive_summarization: None,
5061 reflection_threshold: None,
5062 consolidate_on_exit: None,
5063 workspace: None,
5064 max_total_tokens: None,
5065 audit_trail: None,
5066 audit_user_id: None,
5067 audit_tenant_id: None,
5068 audit_delegation_chain: Vec::new(),
5069 context: Default::default(),
5070 })
5071 .build()
5072 .unwrap();
5073
5074 orch.run("do work").await.unwrap();
5075
5076 let systems = provider.systems_seen.lock().unwrap();
5077 assert!(systems.len() >= 2, "expected at least 2 LLM calls");
5078 assert!(
5081 systems[1].contains("[ORCH_GUARD_ACTIVE]"),
5082 "sub-agent system prompt should contain orchestrator guardrail marker; got: {}",
5083 systems[1]
5084 );
5085 }
5086
5087 #[test]
5088 fn entry_agent_prompt_allows_direct_answers() {
5089 let tools = ["bash".to_string()];
5090 let agents: Vec<(&str, &str, &[String])> = vec![("worker", "does work", &tools)];
5091 let recipes = [("deep-review", "review the diff")];
5092 let p = build_entry_agent_prompt(&agents, true, DispatchMode::Parallel, &recipes);
5093 assert!(
5095 !p.contains("Never respond to the user directly"),
5096 "entry agent must be allowed to answer directly"
5097 );
5098 let low = p.to_lowercase();
5099 assert!(
5100 low.contains("answer directly"),
5101 "prompt should instruct direct answers for simple turns"
5102 );
5103 assert!(
5106 low.contains("default for real work") || low.contains("substantive or multi-part"),
5107 "prompt should make delegation the default for substantive work: {p}"
5108 );
5109 assert!(p.contains("worker"), "lists sub-agents");
5110 assert!(p.contains("deep-review"), "lists workflow recipes");
5111 assert!(p.contains("run_workflow"), "mentions the workflow tool");
5112 }
5113
5114 #[test]
5115 fn entry_agent_prompt_without_recipes_omits_workflow_block() {
5116 let agents: Vec<(&str, &str, &[String])> = vec![];
5117 let p = build_entry_agent_prompt(&agents, false, DispatchMode::Parallel, &[]);
5118 assert!(
5119 !p.contains("run_workflow"),
5120 "no recipes → no workflow block"
5121 );
5122 assert!(
5123 p.contains("none configured"),
5124 "empty squad → 'handle everything yourself'"
5125 );
5126 }
5127
5128 #[test]
5129 fn entry_prompt_has_clarify_rule_when_question_tool_available() {
5130 let agents: Vec<(&str, &str, &[String])> = vec![];
5134 let with = build_entry_agent_prompt_ext(
5135 &agents,
5136 false,
5137 DispatchMode::Parallel,
5138 &[],
5139 EntryCaps {
5140 ask_user: true,
5141 ..Default::default()
5142 },
5143 );
5144 let low = with.to_lowercase();
5145 assert!(
5146 low.contains("guessing would cause rework"),
5147 "clarify rule missing: {with}"
5148 );
5149 assert!(
5150 low.contains("question"),
5151 "rule must name the question tool: {with}"
5152 );
5153 assert!(
5154 low.contains("state") && low.contains("assumption"),
5155 "rule must give the proceed-with-assumptions alternative: {with}"
5156 );
5157 let without = build_entry_agent_prompt_ext(
5158 &agents,
5159 false,
5160 DispatchMode::Parallel,
5161 &[],
5162 EntryCaps::default(),
5163 );
5164 assert!(
5165 !without
5166 .to_lowercase()
5167 .contains("guessing would cause rework"),
5168 "no question tool → no clarify rule: {without}"
5169 );
5170 }
5171
5172 #[tokio::test]
5175 async fn entry_agent_runs_its_own_direct_tools() {
5176 use std::sync::atomic::{AtomicUsize, Ordering};
5177
5178 struct CountTool(Arc<AtomicUsize>);
5179 impl Tool for CountTool {
5180 fn definition(&self) -> ToolDefinition {
5181 ToolDefinition {
5182 name: "noop".into(),
5183 description: "no-op".into(),
5184 input_schema: json!({"type": "object", "properties": {}}),
5185 }
5186 }
5187 fn execute(
5188 &self,
5189 _ctx: &crate::ExecutionContext,
5190 _input: serde_json::Value,
5191 ) -> std::pin::Pin<
5192 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5193 > {
5194 self.0.fetch_add(1, Ordering::SeqCst);
5195 Box::pin(async { Ok(ToolOutput::success("ok")) })
5196 }
5197 }
5198
5199 let calls = Arc::new(AtomicUsize::new(0));
5200 let provider = Arc::new(TailCapturingProvider {
5201 responses: Mutex::new(vec![
5202 CompletionResponse {
5204 content: vec![ContentBlock::ToolUse {
5205 id: "t1".into(),
5206 name: "noop".into(),
5207 input: json!({}),
5208 }],
5209 stop_reason: StopReason::ToolUse,
5210 reasoning: None,
5211 usage: TokenUsage::default(),
5212 model: None,
5213 },
5214 text_end("done"),
5215 ]),
5216 tails: Mutex::new(vec![]),
5217 });
5218 let mut orch = Orchestrator::builder(provider)
5219 .entry_agent(vec![Arc::new(CountTool(calls.clone()))])
5220 .sub_agent("worker", "does work", "you work")
5221 .build()
5222 .unwrap();
5223 let out = orch.run("do a thing").await.unwrap();
5224 assert_eq!(out.result, "done");
5225 assert_eq!(
5226 calls.load(Ordering::SeqCst),
5227 1,
5228 "entry agent must execute its own direct tool on its own runner"
5229 );
5230 }
5231
5232 #[tokio::test]
5237 async fn entry_agent_can_both_act_and_delegate() {
5238 use std::sync::atomic::{AtomicUsize, Ordering};
5239
5240 struct CountTool(Arc<AtomicUsize>);
5241 impl Tool for CountTool {
5242 fn definition(&self) -> ToolDefinition {
5243 ToolDefinition {
5244 name: "noop".into(),
5245 description: "no-op".into(),
5246 input_schema: json!({"type": "object", "properties": {}}),
5247 }
5248 }
5249 fn execute(
5250 &self,
5251 _ctx: &crate::ExecutionContext,
5252 _input: serde_json::Value,
5253 ) -> std::pin::Pin<
5254 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5255 > {
5256 self.0.fetch_add(1, Ordering::SeqCst);
5257 Box::pin(async { Ok(ToolOutput::success("ok")) })
5258 }
5259 }
5260
5261 let direct_calls = Arc::new(AtomicUsize::new(0));
5262 let provider = Arc::new(TailCapturingProvider {
5263 responses: Mutex::new(vec![
5264 CompletionResponse {
5266 content: vec![ContentBlock::ToolUse {
5267 id: "t1".into(),
5268 name: "noop".into(),
5269 input: json!({}),
5270 }],
5271 stop_reason: StopReason::ToolUse,
5272 reasoning: None,
5273 usage: TokenUsage::default(),
5274 model: None,
5275 },
5276 CompletionResponse {
5279 content: vec![ContentBlock::ToolUse {
5280 id: "d1".into(),
5281 name: "delegate_task".into(),
5282 input: json!({"tasks": [{"agent": "worker", "task": "do it"}]}),
5283 }],
5284 stop_reason: StopReason::ToolUse,
5285 reasoning: None,
5286 usage: TokenUsage::default(),
5287 model: None,
5288 },
5289 text_end("worker done"), text_end("synth"), ]),
5292 tails: Mutex::new(vec![]),
5293 });
5294 let mut orch = Orchestrator::builder(provider.clone())
5295 .entry_agent(vec![Arc::new(CountTool(direct_calls.clone()))])
5296 .sub_agent("worker", "does work", "you work")
5297 .build()
5298 .unwrap();
5299 let out = orch.run("act then delegate").await.unwrap();
5300 assert_eq!(out.result, "synth");
5301 assert_eq!(
5302 direct_calls.load(Ordering::SeqCst),
5303 1,
5304 "entry agent ran its own direct tool"
5305 );
5306 assert!(
5310 provider.tails.lock().unwrap().len() >= 4,
5311 "delegate_task must still be present and spawn the sub-agent"
5312 );
5313 }
5314
5315 #[test]
5316 fn sub_agent_context_config_default_is_empty() {
5317 let cx = SubAgentContextConfig::default();
5318 assert!(cx.todo_store.is_none());
5319 assert!(cx.context_recall_store.is_none());
5320 assert!(cx.context_window_tokens.is_none());
5321 assert!(!cx.replan_on_verify_fail);
5322 }
5323
5324 #[tokio::test(flavor = "multi_thread")]
5327 async fn entry_agent_goal_is_wired() {
5328 let main = Arc::new(TailCapturingProvider {
5332 responses: Mutex::new(vec![text_end("first attempt"), text_end("final result")]),
5333 tails: Mutex::new(vec![]),
5334 });
5335 let judge = Arc::new(TailCapturingProvider {
5336 responses: Mutex::new(vec![
5337 text_end("GOAL_MET: NO: no concrete evidence of the endpoint"),
5338 text_end("GOAL_MET: YES"),
5339 ]),
5340 tails: Mutex::new(vec![]),
5341 });
5342 let mut orch = Orchestrator::builder(main.clone())
5343 .entry_agent(vec![])
5344 .entry_goal(
5345 crate::agent::goal::GoalCondition::new(
5346 "ship the /health endpoint",
5347 Arc::new(crate::llm::BoxedProvider::from_arc(judge)),
5348 )
5349 .with_max_continuations(2),
5350 )
5351 .sub_agent("worker", "does work", "you work")
5352 .build()
5353 .unwrap();
5354 let out = orch.run("add /health").await.unwrap();
5355 assert_eq!(out.result, "final result");
5356 let tails = main.tails.lock().unwrap();
5357 assert!(
5358 tails
5359 .iter()
5360 .any(|t| t.contains("objective is not yet complete")),
5361 "judge continuation must be injected into the entry agent: {tails:?}"
5362 );
5363 }
5364
5365 #[test]
5366 fn clarify_rule_forbids_re_asking_specified_facts() {
5367 let agents: Vec<(&str, &str, &[String])> = vec![];
5368 let p = build_entry_agent_prompt_ext(
5369 &agents,
5370 false,
5371 DispatchMode::Parallel,
5372 &[],
5373 EntryCaps {
5374 ask_user: true,
5375 ..Default::default()
5376 },
5377 );
5378 let low = p.to_lowercase();
5379 assert!(
5380 low.contains("never re-ask anything the user already specified"),
5381 "must forbid re-asking specified facts: {p}"
5382 );
5383 assert!(
5387 low.contains("resolve a constraint")
5388 && low.contains("scratch subdirectory inside the workspace"),
5389 "constraints must be resolved to where the file tools can act: {p}"
5390 );
5391 }
5392
5393 #[test]
5394 fn entry_prompt_discipline_rules_gated_on_caps() {
5395 let agents: Vec<(&str, &str, &[String])> = vec![];
5396 let full = build_entry_agent_prompt_ext(
5397 &agents,
5398 false,
5399 DispatchMode::Parallel,
5400 &[],
5401 EntryCaps {
5402 ask_user: false,
5403 set_goal: true,
5404 set_scope: true,
5405 },
5406 );
5407 assert!(full.contains("set_goal"), "goal rule present: {full}");
5408 assert!(full.contains("set_scope"), "scope rule present: {full}");
5409 let none = build_entry_agent_prompt_ext(
5410 &agents,
5411 false,
5412 DispatchMode::Parallel,
5413 &[],
5414 EntryCaps::default(),
5415 );
5416 assert!(!none.contains("set_goal") && !none.contains("set_scope"));
5417 }
5418
5419 #[tokio::test(flavor = "multi_thread")]
5420 async fn entry_agent_set_goal_tool_installs_runtime_goal() {
5421 let main = Arc::new(TailCapturingProvider {
5426 responses: Mutex::new(vec![
5427 CompletionResponse {
5428 content: vec![ContentBlock::ToolUse {
5429 id: "g1".into(),
5430 name: "set_goal".into(),
5431 input: json!({"objective": "demonstrate the endpoint works"}),
5432 }],
5433 stop_reason: StopReason::ToolUse,
5434 reasoning: None,
5435 usage: TokenUsage::default(),
5436 model: None,
5437 },
5438 text_end("first stop"), text_end("real result"), ]),
5441 tails: Mutex::new(vec![]),
5442 });
5443 let judge = Arc::new(TailCapturingProvider {
5444 responses: Mutex::new(vec![
5445 text_end("GOAL_MET: NO: nothing demonstrated"),
5446 text_end("GOAL_MET: YES"),
5447 ]),
5448 tails: Mutex::new(vec![]),
5449 });
5450 let mut orch = Orchestrator::builder(main.clone())
5451 .entry_agent(vec![])
5452 .entry_goal_judge(Arc::new(crate::llm::BoxedProvider::from_arc(judge.clone())))
5453 .sub_agent("worker", "does work", "you work")
5454 .max_turns(20)
5455 .build()
5456 .unwrap();
5457 let out = orch.run("ship the endpoint").await.unwrap();
5458 assert_eq!(out.result, "real result");
5459 assert_eq!(
5460 judge.tails.lock().unwrap().len(),
5461 2,
5462 "judge consulted at both stops"
5463 );
5464 let tails = main.tails.lock().unwrap();
5465 assert!(
5466 tails.iter().any(|t| t.contains("not yet complete")),
5467 "the runtime-installed goal gated the stop: {tails:?}"
5468 );
5469 }
5470
5471 #[tokio::test(flavor = "multi_thread")]
5472 async fn entry_agent_auto_wires_delegation_nudge() {
5473 struct NoopTool;
5478 impl Tool for NoopTool {
5479 fn definition(&self) -> ToolDefinition {
5480 ToolDefinition {
5481 name: "noop".into(),
5482 description: "no-op".into(),
5483 input_schema: json!({"type": "object", "properties": {}}),
5484 }
5485 }
5486 fn execute(
5487 &self,
5488 _ctx: &crate::ExecutionContext,
5489 _input: serde_json::Value,
5490 ) -> std::pin::Pin<
5491 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5492 > {
5493 Box::pin(async { Ok(ToolOutput::success("ok")) })
5494 }
5495 }
5496
5497 let mut responses: Vec<CompletionResponse> = (0..ENTRY_DELEGATION_NUDGE_AFTER)
5498 .map(|i| CompletionResponse {
5499 content: vec![ContentBlock::ToolUse {
5500 id: format!("t{i}"),
5501 name: "noop".into(),
5502 input: json!({}),
5503 }],
5504 stop_reason: StopReason::ToolUse,
5505 reasoning: None,
5506 usage: TokenUsage::default(),
5507 model: None,
5508 })
5509 .collect();
5510 responses.push(text_end("done"));
5511 let provider = Arc::new(TailCapturingProvider {
5512 responses: Mutex::new(responses),
5513 tails: Mutex::new(vec![]),
5514 });
5515 let mut orch = Orchestrator::builder(provider.clone())
5516 .entry_agent(vec![Arc::new(NoopTool)])
5517 .sub_agent("worker", "does work", "you work")
5518 .max_turns(30)
5519 .build()
5520 .unwrap();
5521 let out = orch.run("a substantive task").await.unwrap();
5522 assert_eq!(out.result, "done");
5523 let tails = provider.tails.lock().unwrap();
5524 assert!(
5525 tails.iter().any(|t| t.contains("[delegation check]")),
5526 "the nudge must reach the model after {ENTRY_DELEGATION_NUDGE_AFTER} solo calls: {tails:?}"
5527 );
5528 }
5529
5530 struct TailCapturingProvider {
5531 responses: Mutex<Vec<CompletionResponse>>,
5532 tails: Mutex<Vec<String>>,
5533 }
5534 impl LlmProvider for TailCapturingProvider {
5535 async fn complete(
5536 &self,
5537 request: crate::llm::types::CompletionRequest,
5538 ) -> Result<CompletionResponse, crate::error::Error> {
5539 let tail = request
5540 .messages
5541 .last()
5542 .map(|m| {
5543 m.content
5544 .iter()
5545 .filter_map(|b| match b {
5546 ContentBlock::Text { text } => Some(text.as_str()),
5547 _ => None,
5548 })
5549 .collect::<Vec<_>>()
5550 .join("\n")
5551 })
5552 .unwrap_or_default();
5553 self.tails.lock().unwrap().push(tail);
5554 let mut r = self.responses.lock().unwrap();
5555 if r.is_empty() {
5556 return Err(crate::error::Error::Agent("no more responses".into()));
5557 }
5558 Ok(r.remove(0))
5559 }
5560 }
5561
5562 fn text_end(t: &str) -> CompletionResponse {
5563 CompletionResponse {
5564 content: vec![ContentBlock::Text { text: t.into() }],
5565 stop_reason: StopReason::EndTurn,
5566 reasoning: None,
5567 usage: TokenUsage::default(),
5568 model: None,
5569 }
5570 }
5571
5572 fn delegate_then_finish() -> Vec<CompletionResponse> {
5573 vec![
5574 CompletionResponse {
5576 content: vec![ContentBlock::ToolUse {
5577 id: "c1".into(),
5578 name: "delegate_task".into(),
5579 input: json!({"tasks": [{"agent": "worker", "task": "do work"}]}),
5580 }],
5581 stop_reason: StopReason::ToolUse,
5582 reasoning: None,
5583 usage: TokenUsage::default(),
5584 model: None,
5585 },
5586 CompletionResponse {
5588 content: vec![ContentBlock::Text {
5589 text: "done".into(),
5590 }],
5591 stop_reason: StopReason::EndTurn,
5592 reasoning: None,
5593 usage: TokenUsage::default(),
5594 model: None,
5595 },
5596 CompletionResponse {
5598 content: vec![ContentBlock::Text {
5599 text: "synth".into(),
5600 }],
5601 stop_reason: StopReason::EndTurn,
5602 reasoning: None,
5603 usage: TokenUsage::default(),
5604 model: None,
5605 },
5606 ]
5607 }
5608
5609 #[tokio::test]
5613 async fn recitation_propagates_to_delegated_sub_agent() {
5614 let store = Arc::new(crate::tool::builtins::TodoStore::new());
5615 let tools = crate::tool::builtins::todo_tools(store.clone());
5616 tools
5617 .iter()
5618 .find(|t| t.definition().name == "todowrite")
5619 .unwrap()
5620 .execute(
5621 &crate::ExecutionContext::default(),
5622 json!({"todos": [{"content": "finish the parser", "status": "in_progress", "priority": "high"}]}),
5623 )
5624 .await
5625 .unwrap();
5626
5627 let provider = Arc::new(TailCapturingProvider {
5628 responses: Mutex::new(delegate_then_finish()),
5629 tails: Mutex::new(vec![]),
5630 });
5631 let cfg = SubAgentConfig {
5632 name: "worker".into(),
5633 description: "Worker".into(),
5634 system_prompt: "You work.".into(),
5635 context: SubAgentContextConfig {
5636 todo_store: Some(store),
5637 ..Default::default()
5638 },
5639 ..Default::default()
5640 };
5641 let mut orch = Orchestrator::builder(provider.clone())
5642 .sub_agent_full(cfg)
5643 .build()
5644 .unwrap();
5645 orch.run("do work").await.unwrap();
5646
5647 let tails = provider.tails.lock().unwrap();
5648 assert!(
5649 tails.iter().any(|t| t.contains("[plan — open items")),
5650 "the delegated sub-agent must recite its plan; tails: {tails:?}"
5651 );
5652 assert!(
5653 tails.iter().any(|t| t.contains("[>] finish the parser")),
5654 "recitation should carry the open item; tails: {tails:?}"
5655 );
5656 }
5657
5658 #[tokio::test]
5661 async fn default_context_means_no_recitation_for_sub_agent() {
5662 let provider = Arc::new(TailCapturingProvider {
5663 responses: Mutex::new(delegate_then_finish()),
5664 tails: Mutex::new(vec![]),
5665 });
5666 let cfg = SubAgentConfig {
5667 name: "worker".into(),
5668 description: "Worker".into(),
5669 system_prompt: "You work.".into(),
5670 ..Default::default() };
5672 let mut orch = Orchestrator::builder(provider.clone())
5673 .sub_agent_full(cfg)
5674 .build()
5675 .unwrap();
5676 orch.run("do work").await.unwrap();
5677
5678 let tails = provider.tails.lock().unwrap();
5679 assert!(
5680 !tails.iter().any(|t| t.contains("[plan — open items")),
5681 "default-context sub-agent must not recite; tails: {tails:?}"
5682 );
5683 }
5684
5685 #[tokio::test]
5689 async fn recitation_propagates_to_form_squad_member() {
5690 let store = Arc::new(crate::tool::builtins::TodoStore::new());
5691 let tools = crate::tool::builtins::todo_tools(store.clone());
5692 tools
5693 .iter()
5694 .find(|t| t.definition().name == "todowrite")
5695 .unwrap()
5696 .execute(
5697 &crate::ExecutionContext::default(),
5698 json!({"todos": [{"content": "ship the feature", "status": "in_progress", "priority": "high"}]}),
5699 )
5700 .await
5701 .unwrap();
5702
5703 let provider = Arc::new(TailCapturingProvider {
5704 responses: Mutex::new(vec![
5705 CompletionResponse {
5707 content: vec![ContentBlock::ToolUse {
5708 id: "c1".into(),
5709 name: "form_squad".into(),
5710 input: json!({"tasks": [
5711 {"agent": "worker", "task": "do A"},
5712 {"agent": "helper", "task": "do B"}
5713 ]}),
5714 }],
5715 stop_reason: StopReason::ToolUse,
5716 reasoning: None,
5717 usage: TokenUsage::default(),
5718 model: None,
5719 },
5720 text_end("A done"),
5721 text_end("B done"),
5722 text_end("synth"),
5723 ]),
5724 tails: Mutex::new(vec![]),
5725 });
5726 let worker = SubAgentConfig {
5728 name: "worker".into(),
5729 description: "Worker".into(),
5730 system_prompt: "You work.".into(),
5731 context: SubAgentContextConfig {
5732 todo_store: Some(store),
5733 ..Default::default()
5734 },
5735 ..Default::default()
5736 };
5737 let helper = SubAgentConfig {
5738 name: "helper".into(),
5739 description: "Helper".into(),
5740 system_prompt: "You help.".into(),
5741 ..Default::default()
5742 };
5743 let mut orch = Orchestrator::builder(provider.clone())
5744 .sub_agent_full(worker)
5745 .sub_agent_full(helper)
5746 .build()
5747 .unwrap();
5748 orch.run("do work").await.unwrap();
5749
5750 let tails = provider.tails.lock().unwrap();
5751 assert!(
5752 tails.iter().any(|t| t.contains("[>] ship the feature")),
5753 "a form_squad member must recite its plan; tails: {tails:?}"
5754 );
5755 }
5756
5757 #[tokio::test]
5761 async fn replan_propagates_to_delegated_sub_agent() {
5762 struct FailVerify;
5763 impl Tool for FailVerify {
5764 fn definition(&self) -> ToolDefinition {
5765 ToolDefinition {
5766 name: "verify".into(),
5767 description: "verify".into(),
5768 input_schema: json!({"type": "object", "properties": {}}),
5769 }
5770 }
5771 fn execute(
5772 &self,
5773 _ctx: &crate::ExecutionContext,
5774 _input: serde_json::Value,
5775 ) -> std::pin::Pin<
5776 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5777 > {
5778 Box::pin(async {
5779 Ok(ToolOutput::success(
5780 "VERIFY_RESULT: FAIL exit_code=1 command=cargo test".to_string(),
5781 ))
5782 })
5783 }
5784 }
5785
5786 let provider = Arc::new(TailCapturingProvider {
5787 responses: Mutex::new(vec![
5788 CompletionResponse {
5790 content: vec![ContentBlock::ToolUse {
5791 id: "c1".into(),
5792 name: "delegate_task".into(),
5793 input: json!({"tasks": [{"agent": "worker", "task": "do work"}]}),
5794 }],
5795 stop_reason: StopReason::ToolUse,
5796 reasoning: None,
5797 usage: TokenUsage::default(),
5798 model: None,
5799 },
5800 CompletionResponse {
5802 content: vec![ContentBlock::ToolUse {
5803 id: "v1".into(),
5804 name: "verify".into(),
5805 input: json!({}),
5806 }],
5807 stop_reason: StopReason::ToolUse,
5808 reasoning: None,
5809 usage: TokenUsage::default(),
5810 model: None,
5811 },
5812 text_end("done"),
5814 text_end("done"),
5815 text_end("synth"),
5816 ]),
5817 tails: Mutex::new(vec![]),
5818 });
5819 let worker = SubAgentConfig {
5820 name: "worker".into(),
5821 description: "Worker".into(),
5822 system_prompt: "You work.".into(),
5823 tools: vec![Arc::new(FailVerify)],
5824 context: SubAgentContextConfig {
5825 replan_on_verify_fail: true,
5826 ..Default::default()
5827 },
5828 ..Default::default()
5829 };
5830 let mut orch = Orchestrator::builder(provider.clone())
5831 .sub_agent_full(worker)
5832 .build()
5833 .unwrap();
5834 let _ = orch.run("do work").await;
5835
5836 let tails = provider.tails.lock().unwrap();
5837 assert!(
5838 tails.iter().any(|t| t.contains("Verification is RED")),
5839 "the delegated sub-agent must get the replan nudge on a RED verify; tails: {tails:?}"
5840 );
5841 }
5842
5843 #[tokio::test]
5844 async fn orchestrator_guardrails_propagate_to_form_squad_members() {
5845 use crate::agent::guardrail::Guardrail;
5848 use crate::llm::types::CompletionRequest;
5849
5850 struct MarkerGuardrail;
5851 impl Guardrail for MarkerGuardrail {
5852 fn pre_llm(
5853 &self,
5854 request: &mut CompletionRequest,
5855 ) -> std::pin::Pin<
5856 Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
5857 > {
5858 request.system = format!("{} [ORCH_GUARD_ACTIVE]", request.system);
5859 Box::pin(async { Ok(()) })
5860 }
5861 }
5862 struct CapturingProvider {
5863 responses: Mutex<Vec<CompletionResponse>>,
5864 systems_seen: Mutex<Vec<String>>,
5865 }
5866 impl LlmProvider for CapturingProvider {
5867 async fn complete(
5868 &self,
5869 request: CompletionRequest,
5870 ) -> Result<CompletionResponse, crate::error::Error> {
5871 self.systems_seen
5872 .lock()
5873 .unwrap()
5874 .push(request.system.clone());
5875 let mut r = self.responses.lock().unwrap();
5876 if r.is_empty() {
5877 return Err(crate::error::Error::Agent("no more responses".into()));
5878 }
5879 Ok(r.remove(0))
5880 }
5881 }
5882 fn text(t: &str) -> CompletionResponse {
5883 CompletionResponse {
5884 content: vec![ContentBlock::Text { text: t.into() }],
5885 stop_reason: StopReason::EndTurn,
5886 reasoning: None,
5887 usage: TokenUsage::default(),
5888 model: None,
5889 }
5890 }
5891 fn bare_sub_agent(name: &str) -> SubAgentConfig {
5892 SubAgentConfig {
5893 name: name.into(),
5894 description: format!("{name} agent"),
5895 system_prompt: "You work.".into(),
5896 tools: vec![],
5897 context_strategy: None,
5898 summarize_threshold: None,
5899 tool_timeout: None,
5900 max_tool_output_bytes: None,
5901 max_turns: None,
5902 max_tokens: None,
5903 response_schema: None,
5904 run_timeout: None,
5905 guardrails: vec![], provider: None,
5907 reasoning_effort: None,
5908 enable_reflection: None,
5909 tool_output_compression_threshold: None,
5910 max_tools_per_turn: None,
5911 tool_profile: None,
5912 max_identical_tool_calls: None,
5913 max_fuzzy_identical_tool_calls: None,
5914 max_tool_calls_per_turn: None,
5915 session_prune_config: None,
5916 enable_recursive_summarization: None,
5917 reflection_threshold: None,
5918 consolidate_on_exit: None,
5919 workspace: None,
5920 max_total_tokens: None,
5921 audit_trail: None,
5922 audit_user_id: None,
5923 audit_tenant_id: None,
5924 audit_delegation_chain: Vec::new(),
5925 context: Default::default(),
5926 }
5927 }
5928
5929 let provider = Arc::new(CapturingProvider {
5930 responses: Mutex::new(vec![
5931 CompletionResponse {
5933 content: vec![ContentBlock::ToolUse {
5934 id: "call-1".into(),
5935 name: "form_squad".into(),
5936 input: json!({"tasks": [
5937 {"agent": "worker", "task": "do A"},
5938 {"agent": "helper", "task": "do B"}
5939 ]}),
5940 }],
5941 stop_reason: StopReason::ToolUse,
5942 reasoning: None,
5943 usage: TokenUsage::default(),
5944 model: None,
5945 },
5946 text("A done."), text("B done."), text("Synthesized."), ]),
5950 systems_seen: Mutex::new(vec![]),
5951 });
5952
5953 let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
5954 let mut orch = Orchestrator::builder(provider.clone())
5955 .guardrail(guardrail)
5956 .sub_agent_full(bare_sub_agent("worker"))
5957 .sub_agent_full(bare_sub_agent("helper"))
5958 .build()
5959 .unwrap();
5960 orch.run("do work").await.unwrap();
5961
5962 let systems = provider.systems_seen.lock().unwrap();
5963 assert!(
5965 systems
5966 .iter()
5967 .skip(1)
5968 .any(|s| s.contains("[ORCH_GUARD_ACTIVE]")),
5969 "a form_squad member's system prompt must contain the orchestrator guardrail marker; got: {systems:?}"
5970 );
5971 }
5972
5973 #[tokio::test]
5981 async fn on_approval_propagates_to_delegated_sub_agents() {
5982 let (danger_tool, executed) = TrackedTool::make("danger_tool");
5983 let (approval, seen) = deny_danger_approval();
5984
5985 let provider = Arc::new(MockProvider::new(vec![
5986 CompletionResponse {
5988 content: vec![ContentBlock::ToolUse {
5989 id: "call-1".into(),
5990 name: "delegate_task".into(),
5991 input: json!({"tasks": [{"agent": "worker", "task": "mutate"}]}),
5992 }],
5993 stop_reason: StopReason::ToolUse,
5994 reasoning: None,
5995 usage: TokenUsage::default(),
5996 model: None,
5997 },
5998 CompletionResponse {
6000 content: vec![ContentBlock::ToolUse {
6001 id: "call-2".into(),
6002 name: "danger_tool".into(),
6003 input: json!({}),
6004 }],
6005 stop_reason: StopReason::ToolUse,
6006 reasoning: None,
6007 usage: TokenUsage::default(),
6008 model: None,
6009 },
6010 CompletionResponse {
6012 content: vec![ContentBlock::Text {
6013 text: "stopped".into(),
6014 }],
6015 stop_reason: StopReason::EndTurn,
6016 reasoning: None,
6017 usage: TokenUsage::default(),
6018 model: None,
6019 },
6020 CompletionResponse {
6022 content: vec![ContentBlock::Text {
6023 text: "done".into(),
6024 }],
6025 stop_reason: StopReason::EndTurn,
6026 reasoning: None,
6027 usage: TokenUsage::default(),
6028 model: None,
6029 },
6030 ]));
6031
6032 let mut orch = Orchestrator::builder(provider.clone())
6033 .on_approval(approval)
6034 .sub_agent_full(SubAgentConfig {
6035 name: "worker".into(),
6036 description: "Worker agent".into(),
6037 system_prompt: "You work.".into(),
6038 tools: vec![danger_tool],
6039 ..Default::default()
6040 })
6041 .build()
6042 .unwrap();
6043
6044 let output = orch.run("do work").await.unwrap();
6045 assert_eq!(output.result, "done");
6046
6047 let names = seen.lock().expect("seen lock").clone();
6048 assert!(
6049 names.iter().any(|n| n == "danger_tool"),
6050 "approval callback must fire for the SUB-AGENT's tool calls; saw: {names:?}"
6051 );
6052 assert!(
6053 !executed.load(std::sync::atomic::Ordering::SeqCst),
6054 "human-denied tool must NOT execute in the sub-agent"
6055 );
6056 let requests = provider.captured_requests.lock().expect("capture lock");
6058 let denied_seen = requests.iter().any(|r| {
6059 r.messages.iter().any(|m| {
6060 m.content.iter().any(|b| match b {
6061 ContentBlock::ToolResult { content, .. } => {
6062 content.contains("denied by human reviewer")
6063 }
6064 _ => false,
6065 })
6066 })
6067 });
6068 assert!(
6069 denied_seen,
6070 "sub-agent must see the human-reviewer denial as a tool result"
6071 );
6072 }
6073
6074 #[tokio::test]
6078 async fn on_approval_propagates_to_form_squad_members() {
6079 let (danger_tool, executed) = TrackedTool::make("danger_tool");
6080 let (approval, seen) = deny_danger_approval();
6081
6082 fn text(t: &str) -> CompletionResponse {
6083 CompletionResponse {
6084 content: vec![ContentBlock::Text { text: t.into() }],
6085 stop_reason: StopReason::EndTurn,
6086 reasoning: None,
6087 usage: TokenUsage::default(),
6088 model: None,
6089 }
6090 }
6091 fn danger_call(id: &str) -> CompletionResponse {
6092 CompletionResponse {
6093 content: vec![ContentBlock::ToolUse {
6094 id: id.into(),
6095 name: "danger_tool".into(),
6096 input: json!({}),
6097 }],
6098 stop_reason: StopReason::ToolUse,
6099 reasoning: None,
6100 usage: TokenUsage::default(),
6101 model: None,
6102 }
6103 }
6104
6105 let provider = Arc::new(MockProvider::new(vec![
6106 CompletionResponse {
6108 content: vec![ContentBlock::ToolUse {
6109 id: "call-1".into(),
6110 name: "form_squad".into(),
6111 input: json!({"tasks": [
6112 {"agent": "worker", "task": "do A"},
6113 {"agent": "helper", "task": "do B"}
6114 ]}),
6115 }],
6116 stop_reason: StopReason::ToolUse,
6117 reasoning: None,
6118 usage: TokenUsage::default(),
6119 model: None,
6120 },
6121 danger_call("call-a"),
6123 danger_call("call-b"),
6124 text("stopped"),
6126 text("stopped"),
6127 text("done"),
6129 ]));
6130
6131 let mut orch = Orchestrator::builder(provider.clone())
6132 .on_approval(approval)
6133 .enable_squads(true)
6134 .sub_agent_full(SubAgentConfig {
6135 name: "worker".into(),
6136 description: "Worker agent".into(),
6137 system_prompt: "You work.".into(),
6138 tools: vec![danger_tool.clone()],
6139 ..Default::default()
6140 })
6141 .sub_agent_full(SubAgentConfig {
6142 name: "helper".into(),
6143 description: "Helper agent".into(),
6144 system_prompt: "You help.".into(),
6145 tools: vec![danger_tool],
6146 ..Default::default()
6147 })
6148 .build()
6149 .unwrap();
6150
6151 let output = orch.run("do work").await.unwrap();
6152 assert_eq!(output.result, "done");
6153
6154 let names = seen.lock().expect("seen lock").clone();
6155 assert!(
6156 names.iter().any(|n| n == "danger_tool"),
6157 "approval callback must fire for squad members' tool calls; saw: {names:?}"
6158 );
6159 assert!(
6160 !executed.load(std::sync::atomic::Ordering::SeqCst),
6161 "human-denied tool must NOT execute in any squad member"
6162 );
6163 }
6164
6165 #[tokio::test]
6168 async fn spawn_agent_forwards_on_approval() {
6169 let (danger_tool, executed) = TrackedTool::make("danger_tool");
6170 let (approval, seen) = deny_danger_approval();
6171
6172 let provider = Arc::new(MockProvider::new(vec![
6173 CompletionResponse {
6175 content: vec![ContentBlock::ToolUse {
6176 id: "call-1".into(),
6177 name: "danger_tool".into(),
6178 input: json!({}),
6179 }],
6180 stop_reason: StopReason::ToolUse,
6181 reasoning: None,
6182 usage: TokenUsage::default(),
6183 model: None,
6184 },
6185 CompletionResponse {
6187 content: vec![ContentBlock::Text {
6188 text: "stopped".into(),
6189 }],
6190 stop_reason: StopReason::EndTurn,
6191 reasoning: None,
6192 usage: TokenUsage::default(),
6193 model: None,
6194 },
6195 ]));
6196
6197 let mut config = make_spawn_config();
6198 config.tool_allowlist = vec!["danger_tool".into()];
6199 let mut tool = build_spawn_tool(provider, config, vec![danger_tool]);
6200 tool.on_approval = Some(approval);
6201
6202 let result = tool
6203 .spawn(SpawnAgentInput {
6204 name: "mutator".into(),
6205 system_prompt: "You mutate.".into(),
6206 tools: vec!["danger_tool".into()],
6207 task: "mutate".into(),
6208 })
6209 .await
6210 .unwrap();
6211
6212 assert!(!result.is_error);
6213 let names = seen.lock().expect("seen lock").clone();
6214 assert!(
6215 names.iter().any(|n| n == "danger_tool"),
6216 "approval callback must fire for the spawned agent's tool calls; saw: {names:?}"
6217 );
6218 assert!(
6219 !executed.load(std::sync::atomic::Ordering::SeqCst),
6220 "human-denied tool must NOT execute in the spawned agent"
6221 );
6222 }
6223
6224 #[test]
6225 fn build_rejects_sub_agent_with_zero_max_turns() {
6226 let provider = Arc::new(MockProvider::new(vec![]));
6227 let result = Orchestrator::builder(provider)
6228 .sub_agent_full(SubAgentConfig {
6229 name: "agent1".into(),
6230 description: "Test agent".into(),
6231 system_prompt: "prompt".into(),
6232 tools: vec![],
6233 context_strategy: None,
6234 summarize_threshold: None,
6235 tool_timeout: None,
6236 max_tool_output_bytes: None,
6237 max_turns: Some(0),
6238 max_tokens: None,
6239 response_schema: None,
6240 run_timeout: None,
6241 guardrails: vec![],
6242 provider: None,
6243 reasoning_effort: None,
6244 enable_reflection: None,
6245 tool_output_compression_threshold: None,
6246 max_tools_per_turn: None,
6247 tool_profile: None,
6248 max_identical_tool_calls: None,
6249 max_fuzzy_identical_tool_calls: None,
6250 max_tool_calls_per_turn: None,
6251 session_prune_config: None,
6252 enable_recursive_summarization: None,
6253 reflection_threshold: None,
6254 consolidate_on_exit: None,
6255 workspace: None,
6256 max_total_tokens: None,
6257 audit_trail: None,
6258 audit_user_id: None,
6259 audit_tenant_id: None,
6260 audit_delegation_chain: Vec::new(),
6261 context: Default::default(),
6262 })
6263 .build();
6264
6265 match result {
6266 Err(e) => assert!(
6267 e.to_string().contains("max_turns must be > 0"),
6268 "expected max_turns error, got: {e}"
6269 ),
6270 Ok(_) => panic!("expected build to fail with zero max_turns"),
6271 }
6272 }
6273
6274 #[test]
6275 fn build_rejects_sub_agent_with_zero_max_tokens() {
6276 let provider = Arc::new(MockProvider::new(vec![]));
6277 let result = Orchestrator::builder(provider)
6278 .sub_agent_full(SubAgentConfig {
6279 name: "agent1".into(),
6280 description: "Test agent".into(),
6281 system_prompt: "prompt".into(),
6282 tools: vec![],
6283 context_strategy: None,
6284 summarize_threshold: None,
6285 tool_timeout: None,
6286 max_tool_output_bytes: None,
6287 max_turns: None,
6288 max_tokens: Some(0),
6289 response_schema: None,
6290 run_timeout: None,
6291 guardrails: vec![],
6292 provider: None,
6293 reasoning_effort: None,
6294 enable_reflection: None,
6295 tool_output_compression_threshold: None,
6296 max_tools_per_turn: None,
6297 tool_profile: None,
6298 max_identical_tool_calls: None,
6299 max_fuzzy_identical_tool_calls: None,
6300 max_tool_calls_per_turn: None,
6301 session_prune_config: None,
6302 enable_recursive_summarization: None,
6303 reflection_threshold: None,
6304 consolidate_on_exit: None,
6305 workspace: None,
6306 max_total_tokens: None,
6307 audit_trail: None,
6308 audit_user_id: None,
6309 audit_tenant_id: None,
6310 audit_delegation_chain: Vec::new(),
6311 context: Default::default(),
6312 })
6313 .build();
6314
6315 match result {
6316 Err(e) => assert!(
6317 e.to_string().contains("max_tokens must be > 0"),
6318 "expected max_tokens error, got: {e}"
6319 ),
6320 Ok(_) => panic!("expected build to fail with zero max_tokens"),
6321 }
6322 }
6323
6324 #[tokio::test]
6325 async fn sub_agent_uses_override_provider() {
6326 use crate::llm::types::CompletionRequest;
6327
6328 struct IdentifiedProvider {
6330 id: String,
6331 responses: Mutex<Vec<CompletionResponse>>,
6332 }
6333
6334 impl LlmProvider for IdentifiedProvider {
6335 async fn complete(
6336 &self,
6337 _request: CompletionRequest,
6338 ) -> Result<CompletionResponse, Error> {
6339 let mut responses = self.responses.lock().expect("lock");
6340 if responses.is_empty() {
6341 return Err(Error::Agent(format!("no more responses for {}", self.id)));
6342 }
6343 Ok(responses.remove(0))
6344 }
6345 }
6346
6347 let opus_provider = Arc::new(IdentifiedProvider {
6349 id: "opus".into(),
6350 responses: Mutex::new(vec![
6351 CompletionResponse {
6353 content: vec![ContentBlock::ToolUse {
6354 id: "call-1".into(),
6355 name: "delegate_task".into(),
6356 input: json!({
6357 "tasks": [{"agent": "cheap", "task": "do cheap work"}]
6358 }),
6359 }],
6360 stop_reason: StopReason::ToolUse,
6361 reasoning: None,
6362 usage: TokenUsage::default(),
6363 model: None,
6364 },
6365 CompletionResponse {
6367 content: vec![ContentBlock::Text {
6368 text: "Done.".into(),
6369 }],
6370 stop_reason: StopReason::EndTurn,
6371 reasoning: None,
6372 usage: TokenUsage::default(),
6373 model: None,
6374 },
6375 ]),
6376 });
6377
6378 let haiku_provider: Arc<BoxedProvider> = Arc::new(BoxedProvider::new(IdentifiedProvider {
6379 id: "haiku".into(),
6380 responses: Mutex::new(vec![
6381 CompletionResponse {
6383 content: vec![ContentBlock::Text {
6384 text: "Cheap work done.".into(),
6385 }],
6386 stop_reason: StopReason::EndTurn,
6387 reasoning: None,
6388 usage: TokenUsage {
6389 input_tokens: 5,
6390 output_tokens: 3,
6391 ..Default::default()
6392 },
6393 model: None,
6394 },
6395 ]),
6396 }));
6397
6398 let mut orch = Orchestrator::builder(opus_provider)
6399 .sub_agent_full(SubAgentConfig {
6400 name: "cheap".into(),
6401 description: "Cheap agent".into(),
6402 system_prompt: "You do cheap work.".into(),
6403 tools: vec![],
6404 context_strategy: None,
6405 summarize_threshold: None,
6406 tool_timeout: None,
6407 max_tool_output_bytes: None,
6408 max_turns: None,
6409 max_tokens: None,
6410 response_schema: None,
6411 run_timeout: None,
6412 guardrails: vec![],
6413 provider: Some(haiku_provider),
6414 reasoning_effort: None,
6415 enable_reflection: None,
6416 tool_output_compression_threshold: None,
6417 max_tools_per_turn: None,
6418 tool_profile: None,
6419 max_identical_tool_calls: None,
6420 max_fuzzy_identical_tool_calls: None,
6421 max_tool_calls_per_turn: None,
6422 session_prune_config: None,
6423 enable_recursive_summarization: None,
6424 reflection_threshold: None,
6425 consolidate_on_exit: None,
6426 workspace: None,
6427 max_total_tokens: None,
6428 audit_trail: None,
6429 audit_user_id: None,
6430 audit_tenant_id: None,
6431 audit_delegation_chain: Vec::new(),
6432 context: Default::default(),
6433 })
6434 .build()
6435 .unwrap();
6436
6437 let output = orch.run("do work cheaply").await.unwrap();
6438 assert_eq!(output.result, "Done.");
6439 assert_eq!(output.tokens_used.input_tokens, 5);
6441 }
6442
6443 #[tokio::test]
6444 async fn sub_agent_inherits_default_provider() {
6445 let provider = Arc::new(MockProvider::new(vec![
6447 CompletionResponse {
6449 content: vec![ContentBlock::ToolUse {
6450 id: "call-1".into(),
6451 name: "delegate_task".into(),
6452 input: json!({
6453 "tasks": [{"agent": "worker", "task": "do work"}]
6454 }),
6455 }],
6456 stop_reason: StopReason::ToolUse,
6457 reasoning: None,
6458 usage: TokenUsage::default(),
6459 model: None,
6460 },
6461 CompletionResponse {
6463 content: vec![ContentBlock::Text {
6464 text: "Work done.".into(),
6465 }],
6466 stop_reason: StopReason::EndTurn,
6467 reasoning: None,
6468 usage: TokenUsage::default(),
6469 model: None,
6470 },
6471 CompletionResponse {
6473 content: vec![ContentBlock::Text {
6474 text: "All done.".into(),
6475 }],
6476 stop_reason: StopReason::EndTurn,
6477 reasoning: None,
6478 usage: TokenUsage::default(),
6479 model: None,
6480 },
6481 ]));
6482
6483 let mut orch = Orchestrator::builder(provider)
6484 .sub_agent_full(SubAgentConfig {
6485 name: "worker".into(),
6486 description: "Worker".into(),
6487 system_prompt: "Work.".into(),
6488 tools: vec![],
6489 context_strategy: None,
6490 summarize_threshold: None,
6491 tool_timeout: None,
6492 max_tool_output_bytes: None,
6493 max_turns: None,
6494 max_tokens: None,
6495 response_schema: None,
6496 run_timeout: None,
6497 guardrails: vec![],
6498 provider: None,
6499 reasoning_effort: None,
6500 enable_reflection: None,
6501 tool_output_compression_threshold: None,
6502 max_tools_per_turn: None,
6503 tool_profile: None,
6504 max_identical_tool_calls: None,
6505 max_fuzzy_identical_tool_calls: None,
6506 max_tool_calls_per_turn: None,
6507 session_prune_config: None,
6508 enable_recursive_summarization: None,
6509 reflection_threshold: None,
6510 consolidate_on_exit: None,
6511 workspace: None,
6512 max_total_tokens: None,
6513 audit_trail: None,
6514 audit_user_id: None,
6515 audit_tenant_id: None,
6516 audit_delegation_chain: Vec::new(),
6517 context: Default::default(),
6518 })
6519 .build()
6520 .unwrap();
6521
6522 let output = orch.run("do work").await.unwrap();
6523 assert_eq!(output.result, "All done.");
6524 }
6525
6526 #[test]
6529 fn form_squad_tool_definition_schema() {
6530 let tools = vec!["web_search".to_string()];
6531 let agents: Vec<(&str, &str, &[String])> = vec![
6532 ("researcher", "Research specialist", tools.as_slice()),
6533 ("analyst", "Analysis expert", &[]),
6534 ];
6535 let def = build_form_squad_tool_schema(&agents);
6536 assert_eq!(def.name, "form_squad");
6537 assert!(
6538 def.description.contains("researcher"),
6539 "description should list agents: {}",
6540 def.description
6541 );
6542 assert!(
6543 def.description.contains("analyst"),
6544 "description should list agents: {}",
6545 def.description
6546 );
6547 assert!(
6548 def.description.contains("blackboard"),
6549 "description should mention shared blackboard: {}",
6550 def.description
6551 );
6552 assert!(
6553 def.description.contains("Unlike delegate_task"),
6554 "description should contrast with delegate_task: {}",
6555 def.description
6556 );
6557 assert_eq!(
6559 def.input_schema["properties"]["tasks"]["type"], "array",
6560 "schema should have tasks array"
6561 );
6562 assert_eq!(
6563 def.input_schema["properties"]["tasks"]["items"]["properties"]["agent"]["type"],
6564 "string",
6565 "tasks items should have agent field"
6566 );
6567 assert_eq!(
6568 def.input_schema["properties"]["tasks"]["items"]["properties"]["task"]["type"],
6569 "string",
6570 "tasks items should have task field"
6571 );
6572 let required = def.input_schema["required"]
6573 .as_array()
6574 .expect("required should be array");
6575 assert!(
6576 required.contains(&json!("tasks")),
6577 "tasks should be required"
6578 );
6579 }
6580
6581 #[tokio::test]
6582 async fn form_squad_dispatches_directly() {
6583 let provider = Arc::new(MockProvider::new(vec![
6586 CompletionResponse {
6588 content: vec![ContentBlock::ToolUse {
6589 id: "call-1".into(),
6590 name: "form_squad".into(),
6591 input: json!({
6592 "tasks": [
6593 {"agent": "researcher", "task": "Research Rust"},
6594 {"agent": "analyst", "task": "Analyze findings"}
6595 ]
6596 }),
6597 }],
6598 stop_reason: StopReason::ToolUse,
6599 reasoning: None,
6600 usage: TokenUsage {
6601 input_tokens: 50,
6602 output_tokens: 20,
6603 ..Default::default()
6604 },
6605 model: None,
6606 },
6607 CompletionResponse {
6609 content: vec![ContentBlock::Text {
6610 text: "Rust is fast and safe.".into(),
6611 }],
6612 stop_reason: StopReason::EndTurn,
6613 reasoning: None,
6614 usage: TokenUsage {
6615 input_tokens: 10,
6616 output_tokens: 8,
6617 ..Default::default()
6618 },
6619 model: None,
6620 },
6621 CompletionResponse {
6623 content: vec![ContentBlock::Text {
6624 text: "Strengths: memory safety.".into(),
6625 }],
6626 stop_reason: StopReason::EndTurn,
6627 reasoning: None,
6628 usage: TokenUsage {
6629 input_tokens: 12,
6630 output_tokens: 10,
6631 ..Default::default()
6632 },
6633 model: None,
6634 },
6635 CompletionResponse {
6637 content: vec![ContentBlock::Text {
6638 text: "Final: Rust is excellent.".into(),
6639 }],
6640 stop_reason: StopReason::EndTurn,
6641 reasoning: None,
6642 usage: TokenUsage {
6643 input_tokens: 60,
6644 output_tokens: 25,
6645 ..Default::default()
6646 },
6647 model: None,
6648 },
6649 ]));
6650
6651 let mut orch = Orchestrator::builder(provider)
6652 .sub_agent("researcher", "Research specialist", "You research.")
6653 .sub_agent("analyst", "Analysis expert", "You analyze.")
6654 .sub_agent("coder", "Coding expert", "You code.")
6655 .build()
6656 .unwrap();
6657
6658 let output = orch.run("Analyze Rust deeply").await.unwrap();
6659 assert_eq!(output.result, "Final: Rust is excellent.");
6660 }
6661
6662 #[tokio::test]
6663 async fn form_squad_tokens_roll_up() {
6664 let provider = Arc::new(MockProvider::new(vec![
6665 CompletionResponse {
6667 content: vec![ContentBlock::ToolUse {
6668 id: "call-1".into(),
6669 name: "form_squad".into(),
6670 input: json!({
6671 "tasks": [
6672 {"agent": "agent_a", "task": "Task A"},
6673 {"agent": "agent_b", "task": "Task B"}
6674 ]
6675 }),
6676 }],
6677 stop_reason: StopReason::ToolUse,
6678 reasoning: None,
6679 usage: TokenUsage {
6680 input_tokens: 50,
6681 output_tokens: 20,
6682 ..Default::default()
6683 },
6684 model: None,
6685 },
6686 CompletionResponse {
6688 content: vec![ContentBlock::Text {
6689 text: "Done A.".into(),
6690 }],
6691 stop_reason: StopReason::EndTurn,
6692 reasoning: None,
6693 usage: TokenUsage {
6694 input_tokens: 10,
6695 output_tokens: 5,
6696 ..Default::default()
6697 },
6698 model: None,
6699 },
6700 CompletionResponse {
6702 content: vec![ContentBlock::Text {
6703 text: "Done B.".into(),
6704 }],
6705 stop_reason: StopReason::EndTurn,
6706 reasoning: None,
6707 usage: TokenUsage {
6708 input_tokens: 12,
6709 output_tokens: 6,
6710 ..Default::default()
6711 },
6712 model: None,
6713 },
6714 CompletionResponse {
6716 content: vec![ContentBlock::Text {
6717 text: "All done.".into(),
6718 }],
6719 stop_reason: StopReason::EndTurn,
6720 reasoning: None,
6721 usage: TokenUsage {
6722 input_tokens: 60,
6723 output_tokens: 25,
6724 ..Default::default()
6725 },
6726 model: None,
6727 },
6728 ]));
6729
6730 let mut orch = Orchestrator::builder(provider)
6731 .sub_agent("agent_a", "Agent A", "You are A.")
6732 .sub_agent("agent_b", "Agent B", "You are B.")
6733 .build()
6734 .unwrap();
6735
6736 let output = orch.run("Collaborate").await.unwrap();
6737 assert_eq!(
6741 output.tokens_used.input_tokens,
6742 50 + 60 + 10 + 12,
6743 "all token levels should roll up"
6744 );
6745 assert_eq!(
6746 output.tokens_used.output_tokens,
6747 20 + 25 + 5 + 6,
6748 "all token levels should roll up"
6749 );
6750 }
6751
6752 #[tokio::test]
6753 async fn form_squad_returns_error_for_unknown_agent() {
6754 let provider = Arc::new(MockProvider::new(vec![
6755 CompletionResponse {
6757 content: vec![ContentBlock::ToolUse {
6758 id: "call-1".into(),
6759 name: "form_squad".into(),
6760 input: json!({
6761 "tasks": [
6762 {"agent": "researcher", "task": "Do research"},
6763 {"agent": "nonexistent", "task": "Do stuff"}
6764 ]
6765 }),
6766 }],
6767 stop_reason: StopReason::ToolUse,
6768 reasoning: None,
6769 usage: TokenUsage::default(),
6770 model: None,
6771 },
6772 CompletionResponse {
6774 content: vec![ContentBlock::Text {
6775 text: "No such agent available.".into(),
6776 }],
6777 stop_reason: StopReason::EndTurn,
6778 reasoning: None,
6779 usage: TokenUsage::default(),
6780 model: None,
6781 },
6782 ]));
6783
6784 let mut orch = Orchestrator::builder(provider)
6785 .sub_agent("researcher", "Research", "prompt")
6786 .sub_agent("analyst", "Analysis", "prompt")
6787 .build()
6788 .unwrap();
6789
6790 let output = orch.run("delegate to unknown squad").await.unwrap();
6791 assert_eq!(output.result, "No such agent available.");
6792 }
6793
6794 #[tokio::test]
6795 async fn form_squad_requires_at_least_two_agents() {
6796 let provider = Arc::new(MockProvider::new(vec![
6797 CompletionResponse {
6799 content: vec![ContentBlock::ToolUse {
6800 id: "call-1".into(),
6801 name: "form_squad".into(),
6802 input: json!({
6803 "tasks": [
6804 {"agent": "researcher", "task": "Solo task"}
6805 ]
6806 }),
6807 }],
6808 stop_reason: StopReason::ToolUse,
6809 reasoning: None,
6810 usage: TokenUsage::default(),
6811 model: None,
6812 },
6813 CompletionResponse {
6815 content: vec![ContentBlock::Text {
6816 text: "Using delegate_task instead.".into(),
6817 }],
6818 stop_reason: StopReason::EndTurn,
6819 reasoning: None,
6820 usage: TokenUsage::default(),
6821 model: None,
6822 },
6823 ]));
6824
6825 let mut orch = Orchestrator::builder(provider)
6826 .sub_agent("researcher", "Research", "prompt")
6827 .sub_agent("analyst", "Analysis", "prompt")
6828 .build()
6829 .unwrap();
6830
6831 let output = orch.run("form solo squad").await.unwrap();
6832 assert_eq!(output.result, "Using delegate_task instead.");
6833 }
6834
6835 #[tokio::test]
6836 async fn form_squad_rejects_duplicate_agents() {
6837 let provider = Arc::new(MockProvider::new(vec![
6838 CompletionResponse {
6840 content: vec![ContentBlock::ToolUse {
6841 id: "call-1".into(),
6842 name: "form_squad".into(),
6843 input: json!({
6844 "tasks": [
6845 {"agent": "researcher", "task": "Task 1"},
6846 {"agent": "researcher", "task": "Task 2"}
6847 ]
6848 }),
6849 }],
6850 stop_reason: StopReason::ToolUse,
6851 reasoning: None,
6852 usage: TokenUsage::default(),
6853 model: None,
6854 },
6855 CompletionResponse {
6857 content: vec![ContentBlock::Text {
6858 text: "Fixed duplicate issue.".into(),
6859 }],
6860 stop_reason: StopReason::EndTurn,
6861 reasoning: None,
6862 usage: TokenUsage::default(),
6863 model: None,
6864 },
6865 ]));
6866
6867 let mut orch = Orchestrator::builder(provider.clone())
6868 .sub_agent("researcher", "Research", "prompt")
6869 .sub_agent("analyst", "Analysis", "prompt")
6870 .build()
6871 .unwrap();
6872
6873 let output = orch.run("form squad with dupes").await.unwrap();
6874 assert_eq!(output.result, "Fixed duplicate issue.");
6875
6876 let reqs = provider.captured_requests.lock().unwrap();
6882 let tool_result_text: String = reqs[1]
6883 .messages
6884 .iter()
6885 .flat_map(|m| m.content.iter())
6886 .filter_map(|b| match b {
6887 ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
6888 _ => None,
6889 })
6890 .collect();
6891 assert!(
6892 tool_result_text.contains("researcher") && tool_result_text.contains("analyst"),
6893 "error must list the available agents: {tool_result_text}"
6894 );
6895 assert!(
6896 tool_result_text.contains("delegate_task"),
6897 "error must point at delegate_task for same-agent parallelism: {tool_result_text}"
6898 );
6899 }
6900
6901 #[tokio::test]
6902 async fn form_squad_private_blackboard() {
6903 use crate::agent::blackboard::InMemoryBlackboard;
6904
6905 let outer_bb = Arc::new(InMemoryBlackboard::new());
6906
6907 let provider = Arc::new(MockProvider::new(vec![
6908 CompletionResponse {
6910 content: vec![ContentBlock::ToolUse {
6911 id: "call-1".into(),
6912 name: "form_squad".into(),
6913 input: json!({
6914 "tasks": [
6915 {"agent": "writer_a", "task": "Write something"},
6916 {"agent": "writer_b", "task": "Write something else"}
6917 ]
6918 }),
6919 }],
6920 stop_reason: StopReason::ToolUse,
6921 reasoning: None,
6922 usage: TokenUsage::default(),
6923 model: None,
6924 },
6925 CompletionResponse {
6927 content: vec![ContentBlock::Text {
6928 text: "Written to squad blackboard.".into(),
6929 }],
6930 stop_reason: StopReason::EndTurn,
6931 reasoning: None,
6932 usage: TokenUsage::default(),
6933 model: None,
6934 },
6935 CompletionResponse {
6937 content: vec![ContentBlock::Text {
6938 text: "Also written.".into(),
6939 }],
6940 stop_reason: StopReason::EndTurn,
6941 reasoning: None,
6942 usage: TokenUsage::default(),
6943 model: None,
6944 },
6945 CompletionResponse {
6947 content: vec![ContentBlock::Text {
6948 text: "Done.".into(),
6949 }],
6950 stop_reason: StopReason::EndTurn,
6951 reasoning: None,
6952 usage: TokenUsage::default(),
6953 model: None,
6954 },
6955 ]));
6956
6957 let mut orch = Orchestrator::builder(provider)
6958 .sub_agent("writer_a", "Writer A", "You write.")
6959 .sub_agent("writer_b", "Writer B", "You write.")
6960 .blackboard(outer_bb.clone())
6961 .build()
6962 .unwrap();
6963
6964 orch.run("write to blackboard").await.unwrap();
6965
6966 let squad_key = "squad:writer_a+writer_b";
6968 let val = outer_bb.read(squad_key).await.unwrap();
6969 assert!(
6970 val.is_some(),
6971 "outer blackboard should have squad result under '{squad_key}'"
6972 );
6973
6974 let agent_key = "agent:writer_a";
6977 let val = outer_bb.read(agent_key).await.unwrap();
6978 assert!(
6979 val.is_none(),
6980 "outer blackboard should NOT have '{agent_key}' — that's on the private blackboard"
6981 );
6982 }
6983
6984 #[tokio::test]
6985 async fn form_squad_error_returns_tool_error_not_hard_error() {
6986 let failing_provider = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
6993
6994 let provider = Arc::new(MockProvider::new(vec![
6995 CompletionResponse {
6997 content: vec![ContentBlock::ToolUse {
6998 id: "call-1".into(),
6999 name: "form_squad".into(),
7000 input: json!({
7001 "tasks": [
7002 {"agent": "agent_a", "task": "Do A"},
7003 {"agent": "agent_b", "task": "Do B"}
7004 ]
7005 }),
7006 }],
7007 stop_reason: StopReason::ToolUse,
7008 reasoning: None,
7009 usage: TokenUsage {
7010 input_tokens: 50,
7011 output_tokens: 20,
7012 ..Default::default()
7013 },
7014 model: None,
7015 },
7016 CompletionResponse {
7018 content: vec![ContentBlock::Text {
7019 text: "Done A.".into(),
7020 }],
7021 stop_reason: StopReason::EndTurn,
7022 reasoning: None,
7023 usage: TokenUsage {
7024 input_tokens: 10,
7025 output_tokens: 5,
7026 ..Default::default()
7027 },
7028 model: None,
7029 },
7030 CompletionResponse {
7032 content: vec![ContentBlock::Text {
7033 text: "Squad failed, falling back.".into(),
7034 }],
7035 stop_reason: StopReason::EndTurn,
7036 reasoning: None,
7037 usage: TokenUsage {
7038 input_tokens: 60,
7039 output_tokens: 25,
7040 ..Default::default()
7041 },
7042 model: None,
7043 },
7044 ]));
7045
7046 let mut orch = Orchestrator::builder(provider)
7047 .sub_agent("agent_a", "Agent A", "You are A.")
7048 .sub_agent_full(SubAgentConfig {
7049 name: "agent_b".into(),
7050 description: "Agent B".into(),
7051 system_prompt: "You are B.".into(),
7052 tools: vec![],
7053 context_strategy: None,
7054 summarize_threshold: None,
7055 tool_timeout: None,
7056 max_tool_output_bytes: None,
7057 max_turns: None,
7058 max_tokens: None,
7059 response_schema: None,
7060 run_timeout: None,
7061 guardrails: vec![],
7062 provider: Some(failing_provider),
7063 reasoning_effort: None,
7064 enable_reflection: None,
7065 tool_output_compression_threshold: None,
7066 max_tools_per_turn: None,
7067 tool_profile: None,
7068 max_identical_tool_calls: None,
7069 max_fuzzy_identical_tool_calls: None,
7070 max_tool_calls_per_turn: None,
7071 session_prune_config: None,
7072 enable_recursive_summarization: None,
7073 reflection_threshold: None,
7074 consolidate_on_exit: None,
7075 workspace: None,
7076 max_total_tokens: None,
7077 audit_trail: None,
7078 audit_user_id: None,
7079 audit_tenant_id: None,
7080 audit_delegation_chain: Vec::new(),
7081 context: Default::default(),
7082 })
7083 .build()
7084 .unwrap();
7085
7086 let output = orch.run("complex task").await.unwrap();
7087 assert_eq!(output.result, "Squad failed, falling back.");
7088 assert!(
7090 output.tokens_used.input_tokens > 50 + 60,
7091 "should include partial squad tokens: {}",
7092 output.tokens_used.input_tokens
7093 );
7094 }
7095
7096 #[tokio::test]
7097 async fn orchestrator_registers_both_tools() {
7098 use crate::llm::types::CompletionRequest;
7099
7100 struct ToolCapturingProvider {
7101 responses: Mutex<Vec<CompletionResponse>>,
7102 tool_names_seen: Mutex<Vec<Vec<String>>>,
7103 }
7104
7105 impl LlmProvider for ToolCapturingProvider {
7106 async fn complete(
7107 &self,
7108 request: CompletionRequest,
7109 ) -> Result<CompletionResponse, Error> {
7110 let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
7111 self.tool_names_seen.lock().expect("lock").push(names);
7112 let mut responses = self.responses.lock().expect("lock");
7113 if responses.is_empty() {
7114 return Err(Error::Agent("no more responses".into()));
7115 }
7116 Ok(responses.remove(0))
7117 }
7118 }
7119
7120 let provider = Arc::new(ToolCapturingProvider {
7121 responses: Mutex::new(vec![CompletionResponse {
7122 content: vec![ContentBlock::Text {
7123 text: "Direct answer.".into(),
7124 }],
7125 stop_reason: StopReason::EndTurn,
7126 reasoning: None,
7127 usage: TokenUsage::default(),
7128 model: None,
7129 }]),
7130 tool_names_seen: Mutex::new(vec![]),
7131 });
7132
7133 let mut orch = Orchestrator::builder(provider.clone())
7135 .sub_agent("researcher", "Research", "prompt")
7136 .sub_agent("analyst", "Analysis", "prompt")
7137 .build()
7138 .unwrap();
7139
7140 orch.run("test").await.unwrap();
7141
7142 let tool_names = provider.tool_names_seen.lock().unwrap();
7143 assert!(
7144 tool_names[0].contains(&"delegate_task".to_string()),
7145 "should have delegate_task: {:?}",
7146 tool_names[0]
7147 );
7148 assert!(
7149 tool_names[0].contains(&"form_squad".to_string()),
7150 "should have form_squad: {:?}",
7151 tool_names[0]
7152 );
7153 }
7154
7155 #[test]
7156 fn orchestrator_single_agent_no_squads() {
7157 let provider = Arc::new(MockProvider::new(vec![]));
7158
7159 let result = Orchestrator::builder(provider)
7161 .sub_agent("researcher", "Research", "prompt")
7162 .build();
7163
7164 assert!(result.is_ok());
7165 }
7166
7167 #[tokio::test]
7168 async fn orchestrator_squads_disabled_explicitly() {
7169 use crate::llm::types::CompletionRequest;
7170
7171 struct ToolCapturingProvider {
7172 responses: Mutex<Vec<CompletionResponse>>,
7173 tool_names_seen: Mutex<Vec<Vec<String>>>,
7174 }
7175
7176 impl LlmProvider for ToolCapturingProvider {
7177 async fn complete(
7178 &self,
7179 request: CompletionRequest,
7180 ) -> Result<CompletionResponse, Error> {
7181 let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
7182 self.tool_names_seen.lock().expect("lock").push(names);
7183 let mut responses = self.responses.lock().expect("lock");
7184 if responses.is_empty() {
7185 return Err(Error::Agent("no more responses".into()));
7186 }
7187 Ok(responses.remove(0))
7188 }
7189 }
7190
7191 let provider = Arc::new(ToolCapturingProvider {
7192 responses: Mutex::new(vec![CompletionResponse {
7193 content: vec![ContentBlock::Text {
7194 text: "Direct answer.".into(),
7195 }],
7196 stop_reason: StopReason::EndTurn,
7197 reasoning: None,
7198 usage: TokenUsage::default(),
7199 model: None,
7200 }]),
7201 tool_names_seen: Mutex::new(vec![]),
7202 });
7203
7204 let mut orch = Orchestrator::builder(provider.clone())
7206 .sub_agent("researcher", "Research", "prompt")
7207 .sub_agent("analyst", "Analysis", "prompt")
7208 .enable_squads(false)
7209 .build()
7210 .unwrap();
7211
7212 orch.run("test").await.unwrap();
7213
7214 let tool_names = provider.tool_names_seen.lock().unwrap();
7215 assert!(
7216 tool_names[0].contains(&"delegate_task".to_string()),
7217 "should have delegate_task: {:?}",
7218 tool_names[0]
7219 );
7220 assert!(
7221 !tool_names[0].contains(&"form_squad".to_string()),
7222 "should NOT have form_squad when disabled: {:?}",
7223 tool_names[0]
7224 );
7225 }
7226
7227 #[test]
7228 fn system_prompt_mentions_both_tools_when_squads_enabled() {
7229 let tools = vec!["web_search".to_string()];
7230 let agents: Vec<(&str, &str, &[String])> = vec![
7231 ("researcher", "Research specialist", tools.as_slice()),
7232 ("analyst", "Analysis expert", &[]),
7233 ];
7234
7235 let prompt = build_system_prompt(&agents, true, DispatchMode::Parallel);
7236 assert!(
7237 prompt.contains("delegate_task"),
7238 "prompt should mention delegate_task: {prompt}"
7239 );
7240 assert!(
7241 prompt.contains("form_squad"),
7242 "prompt should mention form_squad: {prompt}"
7243 );
7244 assert!(
7245 prompt.contains("two delegation tools"),
7246 "prompt should explain both tools: {prompt}"
7247 );
7248 assert!(
7250 prompt.contains("isolation"),
7251 "prompt should mention isolation for delegate_task: {prompt}"
7252 );
7253 assert!(
7254 prompt.contains("blackboard"),
7255 "prompt should mention shared blackboard for form_squad: {prompt}"
7256 );
7257 }
7258
7259 #[test]
7260 fn system_prompt_only_delegate_when_squads_disabled() {
7261 let agents: Vec<(&str, &str, &[String])> = vec![("researcher", "Research specialist", &[])];
7262
7263 let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
7264 assert!(
7265 prompt.contains("delegate_task"),
7266 "prompt should mention delegate_task: {prompt}"
7267 );
7268 assert!(
7269 !prompt.contains("form_squad"),
7270 "prompt should NOT mention form_squad: {prompt}"
7271 );
7272 assert!(
7274 prompt.contains("Decision Process"),
7275 "prompt should contain Decision Process even without squads: {prompt}"
7276 );
7277 assert!(
7278 prompt.contains("Effort Scaling"),
7279 "prompt should contain Effort Scaling even without squads: {prompt}"
7280 );
7281 }
7282
7283 #[tokio::test]
7284 async fn delegate_forwards_on_event_to_sub_agents() {
7285 let provider = Arc::new(MockProvider::new(vec![
7287 CompletionResponse {
7289 content: vec![ContentBlock::ToolUse {
7290 id: "call-1".into(),
7291 name: "delegate_task".into(),
7292 input: json!({
7293 "tasks": [
7294 {"agent": "worker", "task": "do work"}
7295 ]
7296 }),
7297 }],
7298 stop_reason: StopReason::ToolUse,
7299 reasoning: None,
7300 usage: TokenUsage::default(),
7301 model: None,
7302 },
7303 CompletionResponse {
7305 content: vec![ContentBlock::Text {
7306 text: "done".into(),
7307 }],
7308 stop_reason: StopReason::EndTurn,
7309 reasoning: None,
7310 usage: TokenUsage::default(),
7311 model: None,
7312 },
7313 CompletionResponse {
7315 content: vec![ContentBlock::Text {
7316 text: "All done.".into(),
7317 }],
7318 stop_reason: StopReason::EndTurn,
7319 reasoning: None,
7320 usage: TokenUsage::default(),
7321 model: None,
7322 },
7323 ]));
7324
7325 let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
7326 let events_clone = events.clone();
7327 let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
7328 events_clone.lock().expect("test lock").push(event);
7329 });
7330
7331 let mut orch = Orchestrator::builder(provider)
7332 .sub_agent("worker", "Worker agent", "You do work.")
7333 .on_event(on_event)
7334 .build()
7335 .unwrap();
7336
7337 let _output = orch.run("delegate some work").await.unwrap();
7338
7339 let events = events.lock().expect("test lock");
7340
7341 let orchestrator_events: Vec<_> = events
7343 .iter()
7344 .filter(|e| match e {
7345 AgentEvent::RunStarted { agent, .. }
7346 | AgentEvent::TurnStarted { agent, .. }
7347 | AgentEvent::LlmResponse { agent, .. }
7348 | AgentEvent::RunCompleted { agent, .. } => agent == "orchestrator",
7349 _ => false,
7350 })
7351 .collect();
7352 let worker_events: Vec<_> = events
7353 .iter()
7354 .filter(|e| match e {
7355 AgentEvent::RunStarted { agent, .. }
7356 | AgentEvent::TurnStarted { agent, .. }
7357 | AgentEvent::LlmResponse { agent, .. }
7358 | AgentEvent::RunCompleted { agent, .. } => agent == "worker",
7359 _ => false,
7360 })
7361 .collect();
7362
7363 assert!(
7364 !orchestrator_events.is_empty(),
7365 "should have orchestrator events"
7366 );
7367 assert!(
7368 !worker_events.is_empty(),
7369 "should have sub-agent worker events (forwarded via on_event)"
7370 );
7371
7372 let worker_run_started = events
7374 .iter()
7375 .any(|e| matches!(e, AgentEvent::RunStarted { agent, .. } if agent == "worker"));
7376 assert!(
7377 worker_run_started,
7378 "sub-agent should emit RunStarted via forwarded on_event"
7379 );
7380 }
7381
7382 #[tokio::test]
7401 async fn full_audit_trail_end_to_end() {
7402 let long_output = "x".repeat(70_000); let provider = Arc::new(MockProvider::new(vec![
7406 CompletionResponse {
7408 content: vec![
7409 ContentBlock::Text {
7410 text: "I'll delegate to the researcher and coder.".into(),
7411 },
7412 ContentBlock::ToolUse {
7413 id: "orch-call-1".into(),
7414 name: "delegate_task".into(),
7415 input: json!({
7416 "tasks": [
7417 {"agent": "researcher", "task": "Search for Rust concurrency patterns"},
7418 {"agent": "coder", "task": "Read the main.rs file"}
7419 ]
7420 }),
7421 },
7422 ],
7423 stop_reason: StopReason::ToolUse,
7424 reasoning: None,
7425 usage: TokenUsage {
7426 input_tokens: 100,
7427 output_tokens: 40,
7428 ..Default::default()
7429 },
7430 model: None,
7431 },
7432 CompletionResponse {
7434 content: vec![
7435 ContentBlock::Text {
7436 text: "Let me search for Rust concurrency info.".into(),
7437 },
7438 ContentBlock::ToolUse {
7439 id: "res-call-1".into(),
7440 name: "web_search".into(),
7441 input: json!({"query": "rust async concurrency"}),
7442 },
7443 ],
7444 stop_reason: StopReason::ToolUse,
7445 reasoning: None,
7446 usage: TokenUsage {
7447 input_tokens: 20,
7448 output_tokens: 10,
7449 ..Default::default()
7450 },
7451 model: None,
7452 },
7453 CompletionResponse {
7455 content: vec![ContentBlock::Text {
7456 text: "Rust uses async/await with tokio for concurrency.".into(),
7457 }],
7458 stop_reason: StopReason::EndTurn,
7459 reasoning: None,
7460 usage: TokenUsage {
7461 input_tokens: 30,
7462 output_tokens: 15,
7463 ..Default::default()
7464 },
7465 model: None,
7466 },
7467 CompletionResponse {
7469 content: vec![
7470 ContentBlock::Text {
7471 text: "I'll read the main.rs file.".into(),
7472 },
7473 ContentBlock::ToolUse {
7474 id: "cod-call-1".into(),
7475 name: "read_file".into(),
7476 input: json!({"path": "/src/main.rs"}),
7477 },
7478 ],
7479 stop_reason: StopReason::ToolUse,
7480 reasoning: None,
7481 usage: TokenUsage {
7482 input_tokens: 15,
7483 output_tokens: 8,
7484 ..Default::default()
7485 },
7486 model: None,
7487 },
7488 CompletionResponse {
7490 content: vec![ContentBlock::Text {
7491 text: "The main.rs contains the entry point.".into(),
7492 }],
7493 stop_reason: StopReason::EndTurn,
7494 reasoning: None,
7495 usage: TokenUsage {
7496 input_tokens: 25,
7497 output_tokens: 12,
7498 ..Default::default()
7499 },
7500 model: None,
7501 },
7502 CompletionResponse {
7504 content: vec![ContentBlock::Text {
7505 text: "Combined analysis: Rust async is great for concurrency.".into(),
7506 }],
7507 stop_reason: StopReason::EndTurn,
7508 reasoning: None,
7509 usage: TokenUsage {
7510 input_tokens: 200,
7511 output_tokens: 50,
7512 ..Default::default()
7513 },
7514 model: None,
7515 },
7516 ]));
7517
7518 let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
7519 let events_clone = events.clone();
7520 let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
7521 events_clone.lock().expect("test lock").push(event);
7522 });
7523
7524 let long_output_clone = long_output.clone();
7527 let shared_tools: Vec<Arc<dyn Tool>> = vec![
7528 Arc::new(MockTool::new("web_search", &long_output_clone)),
7529 Arc::new(MockTool::new(
7530 "read_file",
7531 "fn main() { println!(\"hello\"); }",
7532 )),
7533 ];
7534 let mut orch = Orchestrator::builder(provider)
7535 .sub_agent_with_tools(
7536 "researcher",
7537 "Research specialist",
7538 "You research topics.",
7539 shared_tools.clone(),
7540 )
7541 .sub_agent_with_tools(
7542 "coder",
7543 "Code expert",
7544 "You read and analyze code.",
7545 shared_tools.clone(),
7546 )
7547 .on_event(on_event)
7548 .build()
7549 .unwrap();
7550
7551 let output = orch
7552 .run("Analyze Rust concurrency and the main.rs file")
7553 .await
7554 .unwrap();
7555
7556 assert_eq!(
7558 output.result,
7559 "Combined analysis: Rust async is great for concurrency."
7560 );
7561 assert_eq!(output.tool_calls_made, 1); let events = events.lock().expect("test lock");
7565
7566 fn agent_of(e: &AgentEvent) -> &str {
7568 match e {
7569 AgentEvent::RunStarted { agent, .. }
7570 | AgentEvent::TurnStarted { agent, .. }
7571 | AgentEvent::LlmResponse { agent, .. }
7572 | AgentEvent::Reasoning { agent, .. }
7573 | AgentEvent::ToolCallStarted { agent, .. }
7574 | AgentEvent::ToolCallCompleted { agent, .. }
7575 | AgentEvent::RunCompleted { agent, .. }
7576 | AgentEvent::RunFailed { agent, .. }
7577 | AgentEvent::SubAgentsDispatched { agent, .. }
7578 | AgentEvent::SubAgentCompleted { agent, .. }
7579 | AgentEvent::ApprovalRequested { agent, .. }
7580 | AgentEvent::ApprovalDecision { agent, .. }
7581 | AgentEvent::ContextSummarized { agent, .. }
7582 | AgentEvent::GuardrailDenied { agent, .. }
7583 | AgentEvent::GuardrailWarned { agent, .. }
7584 | AgentEvent::RequestRouted { agent, .. }
7585 | AgentEvent::GateFired { agent, .. }
7586 | AgentEvent::RetryAttempt { agent, .. }
7587 | AgentEvent::DoomLoopDetected { agent, .. }
7588 | AgentEvent::FuzzyDoomLoopDetected { agent, .. }
7589 | AgentEvent::AutoCompactionTriggered { agent, .. }
7590 | AgentEvent::SessionPruned { agent, .. }
7591 | AgentEvent::ModelEscalated { agent, .. }
7592 | AgentEvent::BudgetExceeded { agent, .. }
7593 | AgentEvent::AgentSpawned { agent, .. }
7594 | AgentEvent::KillSwitchActivated { agent, .. }
7595 | AgentEvent::ToolNameRepaired { agent, .. } => agent,
7596 AgentEvent::SensorEventProcessed { sensor_name, .. } => sensor_name,
7597 AgentEvent::StoryUpdated { story_id, .. } => story_id,
7598 AgentEvent::TaskRouted { decision, .. } => decision,
7599 AgentEvent::WorkflowNodeStarted { node, .. }
7600 | AgentEvent::WorkflowNodeCompleted { node, .. }
7601 | AgentEvent::WorkflowNodeFailed { node, .. } => node,
7602 }
7603 }
7604
7605 let event_summary: Vec<String> = events
7607 .iter()
7608 .enumerate()
7609 .map(|(i, e)| format!("{i}: [{:>12}] {:?}", agent_of(e), std::mem::discriminant(e)))
7610 .collect();
7611
7612 let agents_seen: std::collections::HashSet<&str> = events.iter().map(agent_of).collect();
7614 assert!(
7615 agents_seen.contains("orchestrator"),
7616 "missing orchestrator events.\nEvent stream:\n{}",
7617 event_summary.join("\n")
7618 );
7619 assert!(
7620 agents_seen.contains("researcher"),
7621 "missing researcher events (should be forwarded).\nEvent stream:\n{}",
7622 event_summary.join("\n")
7623 );
7624 assert!(
7625 agents_seen.contains("coder"),
7626 "missing coder events (should be forwarded).\nEvent stream:\n{}",
7627 event_summary.join("\n")
7628 );
7629
7630 let orch_events: Vec<&AgentEvent> = events
7632 .iter()
7633 .filter(|e| agent_of(e) == "orchestrator")
7634 .collect();
7635
7636 assert!(
7638 matches!(orch_events[0], AgentEvent::RunStarted { task, .. } if task.contains("Analyze Rust")),
7639 "first orch event should be RunStarted, got: {:?}",
7640 orch_events[0]
7641 );
7642 assert!(
7644 matches!(orch_events.last().unwrap(), AgentEvent::RunCompleted { .. }),
7645 "last orch event should be RunCompleted, got: {:?}",
7646 orch_events.last().unwrap()
7647 );
7648
7649 let llm_responses: Vec<&AgentEvent> = events
7651 .iter()
7652 .filter(|e| matches!(e, AgentEvent::LlmResponse { .. }))
7653 .collect();
7654 assert!(
7655 llm_responses.len() >= 3,
7656 "expected >= 3 LlmResponse events (1 orch + at least 1 per sub-agent), got {}.\nEvents:\n{}",
7657 llm_responses.len(),
7658 event_summary.join("\n")
7659 );
7660
7661 for llm_event in &llm_responses {
7662 match llm_event {
7663 AgentEvent::LlmResponse {
7664 agent, text, model, ..
7665 } => {
7666 assert_eq!(
7668 model.as_deref(),
7669 Some("mock-model-v1"),
7670 "LlmResponse for '{agent}' should have model name"
7671 );
7672 assert!(
7674 !text.is_empty(),
7675 "LlmResponse for '{agent}' should have non-empty text"
7676 );
7677 }
7678 _ => unreachable!(),
7679 }
7680 }
7681
7682 let tool_started: Vec<&AgentEvent> = events
7684 .iter()
7685 .filter(|e| matches!(e, AgentEvent::ToolCallStarted { .. }))
7686 .collect();
7687 assert!(
7689 tool_started.len() >= 3,
7690 "expected >= 3 ToolCallStarted events, got {}.\nEvents:\n{}",
7691 tool_started.len(),
7692 event_summary.join("\n")
7693 );
7694
7695 let web_search_started = tool_started.iter().find(|e| {
7698 matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "web_search")
7699 });
7700 assert!(
7701 web_search_started.is_some(),
7702 "should have a web_search ToolCallStarted"
7703 );
7704 match web_search_started.unwrap() {
7705 AgentEvent::ToolCallStarted { input, .. } => {
7706 assert!(
7707 input.contains("rust async concurrency"),
7708 "web_search input should contain query, got: {input}"
7709 );
7710 }
7711 _ => unreachable!(),
7712 }
7713
7714 let read_file_started = tool_started.iter().find(|e| {
7716 matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "read_file")
7717 });
7718 assert!(
7719 read_file_started.is_some(),
7720 "should have a read_file ToolCallStarted"
7721 );
7722 match read_file_started.unwrap() {
7723 AgentEvent::ToolCallStarted { input, .. } => {
7724 assert!(
7725 input.contains("/src/main.rs"),
7726 "read_file input should contain path, got: {input}"
7727 );
7728 }
7729 _ => unreachable!(),
7730 }
7731
7732 let delegate_started = tool_started.iter().find(|e| {
7734 matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "delegate_task")
7735 });
7736 assert!(
7737 delegate_started.is_some(),
7738 "should have a delegate_task ToolCallStarted"
7739 );
7740 match delegate_started.unwrap() {
7741 AgentEvent::ToolCallStarted { agent, input, .. } => {
7742 assert_eq!(agent, "orchestrator");
7743 assert!(
7744 input.contains("researcher"),
7745 "delegate_task input should contain agent names, got: {input}"
7746 );
7747 }
7748 _ => unreachable!(),
7749 }
7750
7751 let tool_completed: Vec<&AgentEvent> = events
7753 .iter()
7754 .filter(|e| matches!(e, AgentEvent::ToolCallCompleted { .. }))
7755 .collect();
7756 assert!(
7757 tool_completed.len() >= 3,
7758 "expected >= 3 ToolCallCompleted events, got {}",
7759 tool_completed.len()
7760 );
7761
7762 let web_search_completed = tool_completed.iter().find(|e| {
7764 matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "web_search")
7765 });
7766 assert!(
7767 web_search_completed.is_some(),
7768 "should have a web_search ToolCallCompleted"
7769 );
7770 match web_search_completed.unwrap() {
7771 AgentEvent::ToolCallCompleted {
7772 output, is_error, ..
7773 } => {
7774 assert!(!is_error);
7775 assert!(
7777 output.contains("[truncated:"),
7778 "web_search output (70000 bytes) should be truncated in event, got {} bytes: {}",
7779 output.len(),
7780 &output[..output.len().min(100)]
7781 );
7782 }
7783 _ => unreachable!(),
7784 }
7785
7786 let read_file_completed = tool_completed.iter().find(|e| {
7788 matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "read_file")
7789 });
7790 assert!(
7791 read_file_completed.is_some(),
7792 "should have a read_file ToolCallCompleted"
7793 );
7794 match read_file_completed.unwrap() {
7795 AgentEvent::ToolCallCompleted {
7796 output, is_error, ..
7797 } => {
7798 assert!(!is_error);
7799 assert!(
7800 output.contains("fn main()"),
7801 "read_file output should contain file content, got: {output}"
7802 );
7803 assert!(
7804 !output.contains("[truncated:"),
7805 "read_file output should NOT be truncated"
7806 );
7807 }
7808 _ => unreachable!(),
7809 }
7810
7811 let dispatched: Vec<&AgentEvent> = events
7813 .iter()
7814 .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
7815 .collect();
7816 assert_eq!(dispatched.len(), 1, "expected 1 SubAgentsDispatched event");
7817 match dispatched[0] {
7818 AgentEvent::SubAgentsDispatched { agents, .. } => {
7819 assert!(
7820 agents.contains(&"researcher".to_string()),
7821 "dispatched agents should include researcher"
7822 );
7823 assert!(
7824 agents.contains(&"coder".to_string()),
7825 "dispatched agents should include coder"
7826 );
7827 }
7828 _ => unreachable!(),
7829 }
7830
7831 let completed: Vec<&AgentEvent> = events
7832 .iter()
7833 .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
7834 .collect();
7835 assert_eq!(
7836 completed.len(),
7837 2,
7838 "expected 2 SubAgentCompleted events (one per sub-agent)"
7839 );
7840 for c in &completed {
7841 match c {
7842 AgentEvent::SubAgentCompleted { success, agent, .. } => {
7843 assert!(success, "sub-agent '{agent}' should succeed");
7844 }
7845 _ => unreachable!(),
7846 }
7847 }
7848
7849 for agent_name in &["orchestrator", "researcher", "coder"] {
7851 let agent_events: Vec<&AgentEvent> = events
7852 .iter()
7853 .filter(|e| agent_of(e) == *agent_name)
7854 .collect();
7855 if !agent_events.is_empty() {
7856 assert!(
7857 matches!(agent_events[0], AgentEvent::RunStarted { .. }),
7858 "first event for '{agent_name}' should be RunStarted, got: {:?}",
7859 agent_events[0]
7860 );
7861 }
7862 }
7863
7864 assert_eq!(
7870 output.tokens_used.input_tokens,
7871 100 + 200 + 20 + 30 + 15 + 25,
7872 "total input tokens should include orchestrator + sub-agents"
7873 );
7874 assert_eq!(
7875 output.tokens_used.output_tokens,
7876 40 + 50 + 10 + 15 + 8 + 12,
7877 "total output tokens should include orchestrator + sub-agents"
7878 );
7879
7880 let sub_agent_llm = llm_responses.iter().find(|e| {
7883 matches!(e, AgentEvent::LlmResponse { text, .. }
7884 if text.contains("async/await"))
7885 });
7886 assert!(
7887 sub_agent_llm.is_some(),
7888 "should have a sub-agent LlmResponse with text about async/await"
7889 );
7890
7891 assert!(
7901 events.len() >= 20,
7902 "expected at least 20 events for full audit trail, got {}.\nEvents:\n{}",
7903 events.len(),
7904 event_summary.join("\n")
7905 );
7906 }
7907
7908 #[tokio::test]
7909 async fn sub_agent_run_timeout_fires_when_configured() {
7910 let provider = Arc::new(MockProvider::new(vec![
7914 CompletionResponse {
7916 content: vec![ContentBlock::ToolUse {
7917 id: "call-1".into(),
7918 name: "delegate_task".into(),
7919 input: json!({
7920 "tasks": [{"agent": "slow-agent", "task": "do something"}]
7921 }),
7922 }],
7923 stop_reason: StopReason::ToolUse,
7924 reasoning: None,
7925 usage: TokenUsage::default(),
7926 model: None,
7927 },
7928 ]));
7932
7933 struct SlowProvider;
7935 impl LlmProvider for SlowProvider {
7936 async fn complete(
7937 &self,
7938 _request: CompletionRequest,
7939 ) -> Result<CompletionResponse, Error> {
7940 tokio::time::sleep(Duration::from_secs(3600)).await;
7941 unreachable!()
7942 }
7943 }
7944 let slow_provider = Arc::new(BoxedProvider::new(SlowProvider));
7945
7946 let mut orch = Orchestrator::builder(provider)
7947 .sub_agent_full(SubAgentConfig {
7948 name: "slow-agent".into(),
7949 description: "A slow agent".into(),
7950 system_prompt: "sys".into(),
7951 tools: vec![],
7952 context_strategy: None,
7953 summarize_threshold: None,
7954 tool_timeout: None,
7955 max_tool_output_bytes: None,
7956 max_turns: None,
7957 max_tokens: None,
7958 response_schema: None,
7959 run_timeout: Some(Duration::from_millis(100)),
7960 guardrails: vec![],
7961 provider: Some(slow_provider),
7962 reasoning_effort: None,
7963 enable_reflection: None,
7964 tool_output_compression_threshold: None,
7965 max_tools_per_turn: None,
7966 tool_profile: None,
7967 max_identical_tool_calls: None,
7968 max_fuzzy_identical_tool_calls: None,
7969 max_tool_calls_per_turn: None,
7970 session_prune_config: None,
7971 enable_recursive_summarization: None,
7972 reflection_threshold: None,
7973 consolidate_on_exit: None,
7974 workspace: None,
7975 max_total_tokens: None,
7976 audit_trail: None,
7977 audit_user_id: None,
7978 audit_tenant_id: None,
7979 audit_delegation_chain: Vec::new(),
7980 context: Default::default(),
7981 })
7982 .build()
7983 .unwrap();
7984
7985 let result = orch.run("go").await;
7989 match result {
7992 Ok(output) => {
7993 assert!(
7995 output.result.contains("timeout") || output.result.contains("Timeout"),
7996 "expected timeout in result, got: {}",
7997 output.result
7998 );
7999 }
8000 Err(e) => {
8001 let msg = e.to_string();
8004 assert!(
8005 msg.contains("no more mock responses")
8006 || msg.contains("timeout")
8007 || msg.contains("Timeout"),
8008 "expected timeout-related error, got: {msg}"
8009 );
8010 }
8011 }
8012 }
8013
8014 #[tokio::test]
8023 async fn form_squad_complex_with_tools_events_and_failure() {
8024 use crate::agent::blackboard::InMemoryBlackboard;
8025
8026 let outer_bb = Arc::new(InMemoryBlackboard::new());
8027
8028 let failing_provider = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
8030
8031 let provider = Arc::new(MockProvider::new(vec![
8034 CompletionResponse {
8036 content: vec![ContentBlock::ToolUse {
8037 id: "orch-call-1".into(),
8038 name: "form_squad".into(),
8039 input: json!({
8040 "tasks": [
8041 {"agent": "planner", "task": "Create a plan for the analysis"},
8042 {"agent": "worker", "task": "Compute the metrics"},
8043 {"agent": "reviewer", "task": "Review all findings"}
8044 ]
8045 }),
8046 }],
8047 stop_reason: StopReason::ToolUse,
8048 reasoning: None,
8049 usage: TokenUsage {
8050 input_tokens: 100,
8051 output_tokens: 40,
8052 cache_creation_input_tokens: 5,
8053 cache_read_input_tokens: 3,
8054 reasoning_tokens: 0,
8055 },
8056 model: None,
8057 },
8058 CompletionResponse {
8060 content: vec![ContentBlock::Text {
8061 text: "Plan: Step 1 gather data, Step 2 compute, Step 3 review.".into(),
8062 }],
8063 stop_reason: StopReason::EndTurn,
8064 reasoning: None,
8065 usage: TokenUsage {
8066 input_tokens: 20,
8067 output_tokens: 15,
8068 reasoning_tokens: 8,
8069 ..Default::default()
8070 },
8071 model: None,
8072 },
8073 CompletionResponse {
8075 content: vec![
8076 ContentBlock::Text {
8077 text: "I'll compute the metrics now.".into(),
8078 },
8079 ContentBlock::ToolUse {
8080 id: "worker-call-1".into(),
8081 name: "compute".into(),
8082 input: json!({"expression": "42 * 17"}),
8083 },
8084 ],
8085 stop_reason: StopReason::ToolUse,
8086 reasoning: None,
8087 usage: TokenUsage {
8088 input_tokens: 25,
8089 output_tokens: 12,
8090 ..Default::default()
8091 },
8092 model: None,
8093 },
8094 CompletionResponse {
8096 content: vec![ContentBlock::Text {
8097 text: "Computation result: 714. Analysis complete.".into(),
8098 }],
8099 stop_reason: StopReason::EndTurn,
8100 reasoning: None,
8101 usage: TokenUsage {
8102 input_tokens: 35,
8103 output_tokens: 18,
8104 ..Default::default()
8105 },
8106 model: None,
8107 },
8108 CompletionResponse {
8111 content: vec![ContentBlock::Text {
8112 text: "Squad partial success: plan and computation done, review failed.".into(),
8113 }],
8114 stop_reason: StopReason::EndTurn,
8115 reasoning: None,
8116 usage: TokenUsage {
8117 input_tokens: 200,
8118 output_tokens: 60,
8119 ..Default::default()
8120 },
8121 model: None,
8122 },
8123 ]));
8124
8125 let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8127 let events_clone = events.clone();
8128 let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8129 events_clone.lock().expect("test lock").push(event);
8130 });
8131
8132 let compute_tool: Arc<dyn Tool> = Arc::new(MockTool::new("compute", "714"));
8135
8136 let mut orch = Orchestrator::builder(provider)
8137 .sub_agent_with_tools(
8138 "planner",
8139 "Planning specialist",
8140 "You create plans.",
8141 vec![compute_tool.clone()],
8142 )
8143 .sub_agent_with_tools(
8144 "worker",
8145 "Computation worker",
8146 "You compute metrics.",
8147 vec![compute_tool.clone()],
8148 )
8149 .sub_agent_full(SubAgentConfig {
8150 name: "reviewer".into(),
8151 description: "Review specialist".into(),
8152 system_prompt: "You review findings.".into(),
8153 tools: vec![],
8154 context_strategy: None,
8155 summarize_threshold: None,
8156 tool_timeout: None,
8157 max_tool_output_bytes: None,
8158 max_turns: None,
8159 max_tokens: None,
8160 response_schema: None,
8161 run_timeout: None,
8162 guardrails: vec![],
8163 provider: Some(failing_provider),
8164 reasoning_effort: None,
8165 enable_reflection: None,
8166 tool_output_compression_threshold: None,
8167 max_tools_per_turn: None,
8168 tool_profile: None,
8169 max_identical_tool_calls: None,
8170 max_fuzzy_identical_tool_calls: None,
8171 max_tool_calls_per_turn: None,
8172 session_prune_config: None,
8173 enable_recursive_summarization: None,
8174 reflection_threshold: None,
8175 consolidate_on_exit: None,
8176 workspace: None,
8177 max_total_tokens: None,
8178 audit_trail: None,
8179 audit_user_id: None,
8180 audit_tenant_id: None,
8181 audit_delegation_chain: Vec::new(),
8182 context: Default::default(),
8183 })
8184 .blackboard(outer_bb.clone())
8185 .on_event(on_event)
8186 .build()
8187 .unwrap();
8188
8189 let output = orch.run("Analyze the system performance").await.unwrap();
8190
8191 assert_eq!(
8193 output.result,
8194 "Squad partial success: plan and computation done, review failed."
8195 );
8196
8197 let expected_input = 100 + 200 + 20 + 25 + 35;
8203 let expected_output = 40 + 60 + 15 + 12 + 18;
8204 assert_eq!(
8205 output.tokens_used.input_tokens, expected_input,
8206 "input tokens should sum orchestrator + planner + worker (reviewer failed)"
8207 );
8208 assert_eq!(
8209 output.tokens_used.output_tokens, expected_output,
8210 "output tokens should sum orchestrator + planner + worker"
8211 );
8212 assert_eq!(
8213 output.tokens_used.reasoning_tokens, 8,
8214 "reasoning tokens should come from planner"
8215 );
8216 assert_eq!(
8217 output.tokens_used.cache_creation_input_tokens, 5,
8218 "cache creation tokens from orchestrator"
8219 );
8220 assert_eq!(
8221 output.tokens_used.cache_read_input_tokens, 3,
8222 "cache read tokens from orchestrator"
8223 );
8224
8225 let squad_key = "squad:planner+worker+reviewer";
8228 let squad_val = outer_bb.read(squad_key).await.unwrap();
8229 assert!(
8230 squad_val.is_some(),
8231 "outer blackboard should have squad result under '{squad_key}'"
8232 );
8233 let squad_text = squad_val.unwrap().to_string();
8234 assert!(
8236 squad_text.contains("Plan: Step 1"),
8237 "squad result should include planner's output"
8238 );
8239 assert!(
8241 squad_text.contains("Computation result: 714"),
8242 "squad result should include worker's output"
8243 );
8244 assert!(
8246 squad_text.contains("Error"),
8247 "squad result should include reviewer's error"
8248 );
8249
8250 assert!(
8252 outer_bb.read("agent:planner").await.unwrap().is_none(),
8253 "outer blackboard should NOT have agent:planner"
8254 );
8255 assert!(
8256 outer_bb.read("agent:worker").await.unwrap().is_none(),
8257 "outer blackboard should NOT have agent:worker"
8258 );
8259
8260 let events = events.lock().expect("test lock");
8262
8263 fn agent_of(e: &AgentEvent) -> &str {
8264 match e {
8265 AgentEvent::RunStarted { agent, .. }
8266 | AgentEvent::TurnStarted { agent, .. }
8267 | AgentEvent::LlmResponse { agent, .. }
8268 | AgentEvent::Reasoning { agent, .. }
8269 | AgentEvent::ToolCallStarted { agent, .. }
8270 | AgentEvent::ToolCallCompleted { agent, .. }
8271 | AgentEvent::RunCompleted { agent, .. }
8272 | AgentEvent::RunFailed { agent, .. }
8273 | AgentEvent::SubAgentsDispatched { agent, .. }
8274 | AgentEvent::SubAgentCompleted { agent, .. }
8275 | AgentEvent::ApprovalRequested { agent, .. }
8276 | AgentEvent::ApprovalDecision { agent, .. }
8277 | AgentEvent::ContextSummarized { agent, .. }
8278 | AgentEvent::GuardrailDenied { agent, .. }
8279 | AgentEvent::GuardrailWarned { agent, .. }
8280 | AgentEvent::RequestRouted { agent, .. }
8281 | AgentEvent::GateFired { agent, .. }
8282 | AgentEvent::RetryAttempt { agent, .. }
8283 | AgentEvent::DoomLoopDetected { agent, .. }
8284 | AgentEvent::FuzzyDoomLoopDetected { agent, .. }
8285 | AgentEvent::AutoCompactionTriggered { agent, .. }
8286 | AgentEvent::SessionPruned { agent, .. }
8287 | AgentEvent::ModelEscalated { agent, .. }
8288 | AgentEvent::BudgetExceeded { agent, .. }
8289 | AgentEvent::AgentSpawned { agent, .. }
8290 | AgentEvent::KillSwitchActivated { agent, .. }
8291 | AgentEvent::ToolNameRepaired { agent, .. } => agent,
8292 AgentEvent::SensorEventProcessed { sensor_name, .. } => sensor_name,
8293 AgentEvent::StoryUpdated { story_id, .. } => story_id,
8294 AgentEvent::TaskRouted { decision, .. } => decision,
8295 AgentEvent::WorkflowNodeStarted { node, .. }
8296 | AgentEvent::WorkflowNodeCompleted { node, .. }
8297 | AgentEvent::WorkflowNodeFailed { node, .. } => node,
8298 }
8299 }
8300
8301 fn event_type(e: &AgentEvent) -> &'static str {
8302 match e {
8303 AgentEvent::RunStarted { .. } => "RunStarted",
8304 AgentEvent::TurnStarted { .. } => "TurnStarted",
8305 AgentEvent::LlmResponse { .. } => "LlmResponse",
8306 AgentEvent::Reasoning { .. } => "Reasoning",
8307 AgentEvent::ToolCallStarted { .. } => "ToolCallStarted",
8308 AgentEvent::ToolCallCompleted { .. } => "ToolCallCompleted",
8309 AgentEvent::RunCompleted { .. } => "RunCompleted",
8310 AgentEvent::RunFailed { .. } => "RunFailed",
8311 AgentEvent::SubAgentsDispatched { .. } => "SubAgentsDispatched",
8312 AgentEvent::SubAgentCompleted { .. } => "SubAgentCompleted",
8313 AgentEvent::ApprovalRequested { .. } => "ApprovalRequested",
8314 AgentEvent::ApprovalDecision { .. } => "ApprovalDecision",
8315 AgentEvent::ContextSummarized { .. } => "ContextSummarized",
8316 AgentEvent::GuardrailDenied { .. } => "GuardrailDenied",
8317 AgentEvent::GuardrailWarned { .. } => "GuardrailWarned",
8318 AgentEvent::RequestRouted { .. } => "RequestRouted",
8319 AgentEvent::GateFired { .. } => "GateFired",
8320 AgentEvent::RetryAttempt { .. } => "RetryAttempt",
8321 AgentEvent::DoomLoopDetected { .. } => "DoomLoopDetected",
8322 AgentEvent::FuzzyDoomLoopDetected { .. } => "FuzzyDoomLoopDetected",
8323 AgentEvent::AutoCompactionTriggered { .. } => "AutoCompactionTriggered",
8324 AgentEvent::SessionPruned { .. } => "SessionPruned",
8325 AgentEvent::SensorEventProcessed { .. } => "SensorEventProcessed",
8326 AgentEvent::StoryUpdated { .. } => "StoryUpdated",
8327 AgentEvent::TaskRouted { .. } => "TaskRouted",
8328 AgentEvent::ModelEscalated { .. } => "ModelEscalated",
8329 AgentEvent::BudgetExceeded { .. } => "BudgetExceeded",
8330 AgentEvent::AgentSpawned { .. } => "AgentSpawned",
8331 AgentEvent::KillSwitchActivated { .. } => "KillSwitchActivated",
8332 AgentEvent::WorkflowNodeStarted { .. } => "WorkflowNodeStarted",
8333 AgentEvent::WorkflowNodeCompleted { .. } => "WorkflowNodeCompleted",
8334 AgentEvent::WorkflowNodeFailed { .. } => "WorkflowNodeFailed",
8335 AgentEvent::ToolNameRepaired { .. } => "ToolNameRepaired",
8336 }
8337 }
8338
8339 let event_summary: Vec<String> = events
8340 .iter()
8341 .enumerate()
8342 .map(|(i, e)| format!("{i}: [{:>12}] {}", agent_of(e), event_type(e)))
8343 .collect();
8344 let event_log = event_summary.join("\n");
8345
8346 let agents_seen: std::collections::HashSet<&str> = events.iter().map(agent_of).collect();
8348 assert!(
8349 agents_seen.contains("orchestrator"),
8350 "missing orchestrator events.\n{event_log}"
8351 );
8352 let has_planner = agents_seen.contains("planner");
8354 let has_worker = agents_seen.contains("worker");
8355 assert!(
8356 has_planner || has_worker,
8357 "should have events from at least one successful squad member.\n{event_log}"
8358 );
8359
8360 let dispatched: Vec<&AgentEvent> = events
8362 .iter()
8363 .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
8364 .collect();
8365 assert_eq!(
8366 dispatched.len(),
8367 1,
8368 "expected exactly 1 SubAgentsDispatched event.\n{event_log}"
8369 );
8370 match dispatched[0] {
8371 AgentEvent::SubAgentsDispatched { agents, agent } => {
8372 assert_eq!(
8373 agent, "squad-leader",
8374 "form_squad uses 'squad-leader' label"
8375 );
8376 assert_eq!(agents.len(), 3, "should dispatch 3 squad members");
8377 assert!(agents.contains(&"planner".to_string()));
8378 assert!(agents.contains(&"worker".to_string()));
8379 assert!(agents.contains(&"reviewer".to_string()));
8380 }
8381 _ => unreachable!(),
8382 }
8383
8384 let completed: Vec<&AgentEvent> = events
8386 .iter()
8387 .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
8388 .collect();
8389 assert_eq!(
8390 completed.len(),
8391 4,
8392 "expected 4 SubAgentCompleted events (3 per-agent + 1 aggregate).\n{event_log}"
8393 );
8394
8395 let per_agent: Vec<&AgentEvent> = completed
8397 .iter()
8398 .filter(|e| {
8399 matches!(e, AgentEvent::SubAgentCompleted { agent, .. }
8400 if !agent.starts_with("squad["))
8401 })
8402 .copied()
8403 .collect();
8404 assert_eq!(per_agent.len(), 3, "3 per-agent completion events");
8405
8406 let reviewer_completed = per_agent.iter().find(
8408 |e| matches!(e, AgentEvent::SubAgentCompleted { agent, .. } if agent == "reviewer"),
8409 );
8410 assert!(
8411 reviewer_completed.is_some(),
8412 "should have reviewer SubAgentCompleted"
8413 );
8414 match reviewer_completed.unwrap() {
8415 AgentEvent::SubAgentCompleted { success, .. } => {
8416 assert!(!success, "reviewer should have failed");
8417 }
8418 _ => unreachable!(),
8419 }
8420
8421 let planner_completed = per_agent.iter().find(
8423 |e| matches!(e, AgentEvent::SubAgentCompleted { agent, .. } if agent == "planner"),
8424 );
8425 assert!(
8426 planner_completed.is_some(),
8427 "should have planner SubAgentCompleted"
8428 );
8429 match planner_completed.unwrap() {
8430 AgentEvent::SubAgentCompleted { success, usage, .. } => {
8431 assert!(success, "planner should have succeeded");
8432 assert_eq!(usage.input_tokens, 20);
8433 assert_eq!(usage.output_tokens, 15);
8434 assert_eq!(usage.reasoning_tokens, 8);
8435 }
8436 _ => unreachable!(),
8437 }
8438
8439 let squad_completed = completed.iter().find(|e| {
8441 matches!(e, AgentEvent::SubAgentCompleted { agent, .. }
8442 if agent.starts_with("squad["))
8443 });
8444 assert!(
8445 squad_completed.is_some(),
8446 "should have aggregate squad completion event.\n{event_log}"
8447 );
8448 match squad_completed.unwrap() {
8449 AgentEvent::SubAgentCompleted {
8450 agent,
8451 success,
8452 usage,
8453 } => {
8454 assert!(
8455 agent.contains("planner")
8456 && agent.contains("worker")
8457 && agent.contains("reviewer"),
8458 "aggregate label should list all agents: {agent}"
8459 );
8460 assert!(
8461 !success,
8462 "aggregate should be false because reviewer failed"
8463 );
8464 assert_eq!(usage.input_tokens, 20 + 25 + 35, "aggregate input tokens");
8466 assert_eq!(usage.output_tokens, 15 + 12 + 18, "aggregate output tokens");
8467 }
8468 _ => unreachable!(),
8469 }
8470
8471 let tool_started: Vec<&AgentEvent> = events
8473 .iter()
8474 .filter(|e| {
8475 matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "compute")
8476 })
8477 .collect();
8478 assert!(
8481 !tool_started.is_empty(),
8482 "should have at least one compute ToolCallStarted.\n{event_log}"
8483 );
8484
8485 let tool_completed: Vec<&AgentEvent> = events
8486 .iter()
8487 .filter(|e| {
8488 matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "compute")
8489 })
8490 .collect();
8491 assert!(
8492 !tool_completed.is_empty(),
8493 "should have at least one compute ToolCallCompleted.\n{event_log}"
8494 );
8495 match tool_completed[0] {
8496 AgentEvent::ToolCallCompleted {
8497 output, is_error, ..
8498 } => {
8499 assert!(!is_error, "compute tool should succeed");
8500 assert!(
8501 output.contains("714"),
8502 "compute output should be '714', got: {output}"
8503 );
8504 }
8505 _ => unreachable!(),
8506 }
8507
8508 for agent_name in &["orchestrator", "planner", "worker"] {
8510 let agent_events: Vec<&AgentEvent> = events
8511 .iter()
8512 .filter(|e| agent_of(e) == *agent_name)
8513 .collect();
8514 if !agent_events.is_empty() {
8515 assert!(
8516 matches!(agent_events[0], AgentEvent::RunStarted { .. }),
8517 "first event for '{agent_name}' should be RunStarted, got: {:?}\n{event_log}",
8518 agent_events[0]
8519 );
8520 }
8521 }
8522
8523 let reviewer_events: Vec<&AgentEvent> = events
8525 .iter()
8526 .filter(|e| agent_of(e) == "reviewer")
8527 .collect();
8528 if !reviewer_events.is_empty() {
8529 assert!(
8530 matches!(reviewer_events[0], AgentEvent::RunStarted { .. }),
8531 "reviewer first event should be RunStarted"
8532 );
8533 let has_failed = reviewer_events
8535 .iter()
8536 .any(|e| matches!(e, AgentEvent::RunFailed { .. }));
8537 assert!(
8538 has_failed,
8539 "reviewer should have a RunFailed event.\n{event_log}"
8540 );
8541 }
8542
8543 let dispatch_idx = events
8545 .iter()
8546 .position(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }));
8547 let first_completed_idx = events
8548 .iter()
8549 .position(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }));
8550 if let (Some(d), Some(c)) = (dispatch_idx, first_completed_idx) {
8551 assert!(
8552 d < c,
8553 "SubAgentsDispatched (idx {d}) should precede SubAgentCompleted (idx {c})\n{event_log}"
8554 );
8555 }
8556
8557 let llm_responses: Vec<&AgentEvent> = events
8559 .iter()
8560 .filter(|e| matches!(e, AgentEvent::LlmResponse { .. }))
8561 .collect();
8562 assert!(
8563 !llm_responses.is_empty(),
8564 "should have LlmResponse events.\n{event_log}"
8565 );
8566 for lr in &llm_responses {
8567 match lr {
8568 AgentEvent::LlmResponse { model, .. } => {
8569 assert_eq!(
8570 model.as_deref(),
8571 Some("mock-model-v1"),
8572 "LlmResponse should carry provider model name"
8573 );
8574 }
8575 _ => unreachable!(),
8576 }
8577 }
8578
8579 assert!(
8589 events.len() >= 15,
8590 "expected at least 15 events for complex squad test, got {}.\n{event_log}",
8591 events.len(),
8592 );
8593 }
8594
8595 #[test]
8596 fn build_rejects_empty_sub_agent_name() {
8597 let provider = Arc::new(MockProvider::new(vec![]));
8598 let result = Orchestrator::builder(provider)
8599 .sub_agent("", "Empty name agent", "prompt")
8600 .build();
8601 match result {
8602 Err(Error::Config(msg)) => {
8603 assert!(
8604 msg.contains("must not be empty"),
8605 "expected empty name error, got: {msg}"
8606 );
8607 }
8608 Err(other) => panic!("expected Config error, got: {other:?}"),
8609 Ok(_) => panic!("expected error for empty sub-agent name"),
8610 }
8611 }
8612
8613 #[tokio::test]
8614 async fn instruction_text_wired_to_orchestrator_system_prompt() {
8615 struct CapturingProvider {
8617 captured_systems: Mutex<Vec<String>>,
8618 }
8619 impl LlmProvider for CapturingProvider {
8620 async fn complete(
8621 &self,
8622 request: CompletionRequest,
8623 ) -> Result<CompletionResponse, Error> {
8624 self.captured_systems
8625 .lock()
8626 .expect("lock")
8627 .push(request.system.clone());
8628 Ok(CompletionResponse {
8630 content: vec![ContentBlock::Text {
8631 text: "Task complete.".into(),
8632 }],
8633 stop_reason: StopReason::EndTurn,
8634 reasoning: None,
8635 usage: TokenUsage::default(),
8636 model: None,
8637 })
8638 }
8639 }
8640
8641 let provider = Arc::new(CapturingProvider {
8642 captured_systems: Mutex::new(Vec::new()),
8643 });
8644 let mut orchestrator = Orchestrator::builder(provider.clone())
8645 .sub_agent("agent-a", "Does things", "You are agent A.")
8646 .instruction_text("Always verify your work.")
8647 .build()
8648 .unwrap();
8649
8650 let _output = orchestrator.run("test task").await.unwrap();
8651 let systems = provider.captured_systems.lock().expect("lock").clone();
8652 assert!(!systems.is_empty(), "should have at least one LLM call");
8654 let orchestrator_system = &systems[0];
8655 assert!(
8656 orchestrator_system.contains("# Project Instructions"),
8657 "orchestrator system prompt should contain instruction header"
8658 );
8659 assert!(
8660 orchestrator_system.contains("Always verify your work."),
8661 "orchestrator system prompt should contain instruction text"
8662 );
8663 }
8664
8665 #[tokio::test]
8666 async fn permission_rules_propagate_to_sub_agents() {
8667 let provider = Arc::new(MockProvider::new(vec![
8671 CompletionResponse {
8673 content: vec![ContentBlock::ToolUse {
8674 id: "orch-1".into(),
8675 name: "delegate_task".into(),
8676 input: json!({
8677 "tasks": [{"agent": "worker", "task": "run a bash command"}]
8678 }),
8679 }],
8680 stop_reason: StopReason::ToolUse,
8681 reasoning: None,
8682 usage: TokenUsage::default(),
8683 model: None,
8684 },
8685 CompletionResponse {
8687 content: vec![ContentBlock::ToolUse {
8688 id: "worker-1".into(),
8689 name: "bash".into(),
8690 input: json!({"command": "echo hello"}),
8691 }],
8692 stop_reason: StopReason::ToolUse,
8693 reasoning: None,
8694 usage: TokenUsage::default(),
8695 model: None,
8696 },
8697 CompletionResponse {
8699 content: vec![ContentBlock::Text {
8700 text: "Bash was denied.".into(),
8701 }],
8702 stop_reason: StopReason::EndTurn,
8703 reasoning: None,
8704 usage: TokenUsage::default(),
8705 model: None,
8706 },
8707 CompletionResponse {
8709 content: vec![ContentBlock::Text {
8710 text: "Worker reported bash was denied.".into(),
8711 }],
8712 stop_reason: StopReason::EndTurn,
8713 reasoning: None,
8714 usage: TokenUsage::default(),
8715 model: None,
8716 },
8717 ]));
8718
8719 let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8720 let events_clone = events.clone();
8721 let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8722 events_clone.lock().expect("test lock").push(event);
8723 });
8724
8725 let deny_bash = crate::agent::permission::PermissionRuleset::new(vec![
8726 crate::agent::permission::PermissionRule {
8727 tool: "bash".into(),
8728 pattern: "*".into(),
8729 action: crate::agent::permission::PermissionAction::Deny,
8730 },
8731 ]);
8732
8733 let bash_tool: Arc<dyn Tool> = Arc::new(MockTool::new("bash", "executed"));
8734
8735 let mut orch = Orchestrator::builder(provider)
8736 .sub_agent_with_tools("worker", "Bash worker", "You run bash.", vec![bash_tool])
8737 .permission_rules(deny_bash)
8738 .on_event(on_event)
8739 .build()
8740 .unwrap();
8741
8742 let output = orch.run("run bash via worker").await.unwrap();
8743 assert_eq!(output.result, "Worker reported bash was denied.");
8744
8745 let events = events.lock().expect("test lock");
8748 let worker_tool_events: Vec<_> = events
8749 .iter()
8750 .filter(|e| {
8751 matches!(
8752 e,
8753 AgentEvent::ToolCallStarted { agent, tool_name, .. }
8754 | AgentEvent::ToolCallCompleted { agent, tool_name, .. }
8755 if agent == "worker" && tool_name == "bash"
8756 )
8757 })
8758 .collect();
8759 assert!(
8760 worker_tool_events.is_empty(),
8761 "bash tool calls in worker should be denied (no events emitted), got: {worker_tool_events:?}"
8762 );
8763 }
8764
8765 #[tokio::test]
8766 async fn permission_rules_propagate_to_squad_members() {
8767 let provider = Arc::new(MockProvider::new(vec![
8769 CompletionResponse {
8771 content: vec![ContentBlock::ToolUse {
8772 id: "orch-1".into(),
8773 name: "form_squad".into(),
8774 input: json!({
8775 "tasks": [
8776 {"agent": "alpha", "task": "run bash"},
8777 {"agent": "beta", "task": "say hello"}
8778 ]
8779 }),
8780 }],
8781 stop_reason: StopReason::ToolUse,
8782 reasoning: None,
8783 usage: TokenUsage::default(),
8784 model: None,
8785 },
8786 CompletionResponse {
8788 content: vec![ContentBlock::ToolUse {
8789 id: "alpha-1".into(),
8790 name: "bash".into(),
8791 input: json!({"command": "ls"}),
8792 }],
8793 stop_reason: StopReason::ToolUse,
8794 reasoning: None,
8795 usage: TokenUsage::default(),
8796 model: None,
8797 },
8798 CompletionResponse {
8800 content: vec![ContentBlock::Text {
8801 text: "Bash denied.".into(),
8802 }],
8803 stop_reason: StopReason::EndTurn,
8804 reasoning: None,
8805 usage: TokenUsage::default(),
8806 model: None,
8807 },
8808 CompletionResponse {
8810 content: vec![ContentBlock::Text {
8811 text: "Hello!".into(),
8812 }],
8813 stop_reason: StopReason::EndTurn,
8814 reasoning: None,
8815 usage: TokenUsage::default(),
8816 model: None,
8817 },
8818 CompletionResponse {
8820 content: vec![ContentBlock::Text {
8821 text: "Squad done.".into(),
8822 }],
8823 stop_reason: StopReason::EndTurn,
8824 reasoning: None,
8825 usage: TokenUsage::default(),
8826 model: None,
8827 },
8828 ]));
8829
8830 let deny_bash = crate::agent::permission::PermissionRuleset::new(vec![
8831 crate::agent::permission::PermissionRule {
8832 tool: "bash".into(),
8833 pattern: "*".into(),
8834 action: crate::agent::permission::PermissionAction::Deny,
8835 },
8836 ]);
8837
8838 let bash_tool: Arc<dyn Tool> = Arc::new(MockTool::new("bash", "executed"));
8839
8840 let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8841 let events_clone = events.clone();
8842 let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8843 events_clone.lock().expect("test lock").push(event);
8844 });
8845
8846 let mut orch = Orchestrator::builder(provider)
8847 .sub_agent_with_tools(
8848 "alpha",
8849 "Alpha agent",
8850 "You run bash.",
8851 vec![bash_tool.clone()],
8852 )
8853 .sub_agent("beta", "Beta agent", "You say hello.")
8854 .permission_rules(deny_bash)
8855 .on_event(on_event)
8856 .build()
8857 .unwrap();
8858
8859 let output = orch.run("form a squad").await.unwrap();
8860 assert_eq!(output.result, "Squad done.");
8861
8862 let events = events.lock().expect("test lock");
8864 let bash_events: Vec<_> = events
8865 .iter()
8866 .filter(|e| {
8867 matches!(
8868 e,
8869 AgentEvent::ToolCallStarted { tool_name, .. }
8870 | AgentEvent::ToolCallCompleted { tool_name, .. }
8871 if tool_name == "bash"
8872 )
8873 })
8874 .collect();
8875 assert!(
8876 bash_events.is_empty(),
8877 "bash tool calls in squad should be denied (no events), got: {bash_events:?}"
8878 );
8879 }
8880
8881 #[test]
8882 fn workspace_propagates_from_builder_to_sub_agents() {
8883 let provider = Arc::new(MockProvider::new(vec![]));
8884 let builder = Orchestrator::builder(provider)
8885 .workspace("/shared/workspace")
8886 .sub_agent_full(SubAgentConfig {
8887 name: "agent1".into(),
8888 description: "test".into(),
8889 workspace: None, ..Default::default()
8891 });
8892
8893 let agent = &builder.sub_agents[0];
8894 assert_eq!(
8895 agent.workspace.as_deref(),
8896 Some(std::path::Path::new("/shared/workspace")),
8897 "sub-agent should inherit workspace from builder"
8898 );
8899 }
8900
8901 #[test]
8902 fn sub_agent_workspace_overrides_builder() {
8903 let provider = Arc::new(MockProvider::new(vec![]));
8904 let builder = Orchestrator::builder(provider)
8905 .workspace("/shared/workspace")
8906 .sub_agent_full(SubAgentConfig {
8907 name: "agent1".into(),
8908 description: "test".into(),
8909 workspace: Some("/custom/workspace".into()),
8910 ..Default::default()
8911 });
8912
8913 let agent = &builder.sub_agents[0];
8914 assert_eq!(
8915 agent.workspace.as_deref(),
8916 Some(std::path::Path::new("/custom/workspace")),
8917 "sub-agent should use its own workspace over builder's"
8918 );
8919 }
8920
8921 #[test]
8922 fn no_workspace_when_builder_has_none() {
8923 let provider = Arc::new(MockProvider::new(vec![]));
8924 let builder = Orchestrator::builder(provider).sub_agent_full(SubAgentConfig {
8925 name: "agent1".into(),
8926 description: "test".into(),
8927 workspace: None,
8928 ..Default::default()
8929 });
8930
8931 let agent = &builder.sub_agents[0];
8932 assert!(
8933 agent.workspace.is_none(),
8934 "sub-agent should have no workspace when builder has none"
8935 );
8936 }
8937
8938 #[test]
8939 fn multi_agent_prompt_enabled_by_default() {
8940 let provider = Arc::new(MockProvider::new(vec![]));
8941 let builder = Orchestrator::builder(provider);
8942 assert!(builder.multi_agent_prompt);
8943 }
8944
8945 #[test]
8946 fn multi_agent_prompt_can_be_disabled() {
8947 let provider = Arc::new(MockProvider::new(vec![]));
8948 let builder = Orchestrator::builder(provider).multi_agent_prompt(false);
8949 assert!(!builder.multi_agent_prompt);
8950 }
8951
8952 #[test]
8953 fn build_injects_collab_prompt_when_enabled() {
8954 let provider = Arc::new(MockProvider::new(vec![]));
8955 let mut builder = Orchestrator::builder(provider).sub_agent(
8956 "writer",
8957 "Writes content",
8958 "You are a writer.",
8959 );
8960 assert!(
8962 !builder.sub_agents[0]
8963 .system_prompt
8964 .contains("MULTI-AGENT COLLABORATION PROTOCOL")
8965 );
8966 if builder.multi_agent_prompt {
8968 for agent in &mut builder.sub_agents {
8969 agent
8970 .system_prompt
8971 .push_str(&crate::agent::prompts::render_collab_prompt(
8972 &agent.name,
8973 &agent.description,
8974 ));
8975 }
8976 }
8977 assert!(
8978 builder.sub_agents[0]
8979 .system_prompt
8980 .contains("MULTI-AGENT COLLABORATION PROTOCOL")
8981 );
8982 assert!(builder.sub_agents[0].system_prompt.contains("`writer`"));
8983 assert!(
8984 builder.sub_agents[0]
8985 .system_prompt
8986 .contains("Writes content")
8987 );
8988 }
8989
8990 #[test]
8991 fn build_omits_collab_prompt_when_disabled() {
8992 let provider = Arc::new(MockProvider::new(vec![]));
8993 let mut builder = Orchestrator::builder(provider)
8994 .multi_agent_prompt(false)
8995 .sub_agent("writer", "Writes content", "You are a writer.");
8996 if builder.multi_agent_prompt {
8998 for agent in &mut builder.sub_agents {
8999 agent
9000 .system_prompt
9001 .push_str(&crate::agent::prompts::render_collab_prompt(
9002 &agent.name,
9003 &agent.description,
9004 ));
9005 }
9006 }
9007 assert!(
9008 !builder.sub_agents[0]
9009 .system_prompt
9010 .contains("MULTI-AGENT COLLABORATION PROTOCOL")
9011 );
9012 assert_eq!(builder.sub_agents[0].system_prompt, "You are a writer.");
9013 }
9014
9015 fn make_spawn_config() -> crate::types::SpawnConfig {
9018 crate::types::SpawnConfig {
9019 max_spawned_agents: 3,
9020 tool_allowlist: vec![],
9021 max_turns: 5,
9022 max_tokens: 1024,
9023 max_total_tokens: 10_000,
9024 }
9025 }
9026
9027 fn build_spawn_tool(
9028 provider: Arc<MockProvider>,
9029 config: crate::types::SpawnConfig,
9030 tools: Vec<Arc<dyn Tool>>,
9031 ) -> SpawnAgentTool {
9032 let mut tool_pool = std::collections::HashMap::new();
9033 for tool in &tools {
9034 let name = tool.definition().name;
9035 if config.tool_allowlist.contains(&name) {
9036 tool_pool.insert(name, tool.clone());
9037 }
9038 }
9039 let cached_definition = SpawnAgentTool::build_definition(&config);
9040 SpawnAgentTool {
9041 shared_provider: Arc::new(BoxedProvider::from_arc(provider)),
9042 spawn_config: config,
9043 tool_pool,
9044 spawn_count: Arc::new(std::sync::atomic::AtomicU32::new(0)),
9045 spawned_names: Arc::new(Mutex::new(std::collections::HashSet::new())),
9046 accumulated_tokens: Arc::new(Mutex::new(TokenUsage::default())),
9047 permission_rules: crate::agent::permission::PermissionRuleset::default(),
9048 shared_memory: None,
9049 memory_namespace_prefix: None,
9050 on_event: None,
9051 lsp_manager: None,
9052 observability_mode: crate::agent::observability::ObservabilityMode::Production,
9053 workspace: None,
9054 guardrails: vec![],
9055 audit_trail: None,
9056 audit_user_id: None,
9057 audit_tenant_id: None,
9058 audit_delegation_chain: vec![],
9059 cached_definition,
9060 tenant_tracker: None,
9061 on_approval: None,
9062 learned_permissions: None,
9063 }
9064 }
9065
9066 #[tokio::test]
9067 async fn spawn_agent_basic_execution() {
9068 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9069 content: vec![ContentBlock::Text {
9070 text: "Tax analysis complete.".into(),
9071 }],
9072 usage: TokenUsage {
9073 input_tokens: 50,
9074 output_tokens: 20,
9075 ..Default::default()
9076 },
9077 stop_reason: StopReason::EndTurn,
9078 reasoning: None,
9079 model: None,
9080 }]));
9081
9082 let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9083
9084 let result = tool
9085 .spawn(SpawnAgentInput {
9086 name: "tax_specialist".into(),
9087 system_prompt: "You are a tax law expert.".into(),
9088 tools: vec![],
9089 task: "Analyze tax implications.".into(),
9090 })
9091 .await
9092 .unwrap();
9093
9094 assert!(!result.is_error);
9095 assert!(result.content.contains("Tax analysis complete."));
9096 assert!(result.content.contains("spawn:tax_specialist"));
9097 assert_eq!(
9098 tool.spawn_count.load(std::sync::atomic::Ordering::Relaxed),
9099 1
9100 );
9101 }
9102
9103 #[tokio::test]
9104 async fn spawn_agent_rejects_invalid_name() {
9105 let provider = Arc::new(MockProvider::new(vec![]));
9106 let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9107
9108 let invalid_names = vec![
9109 "Tax-Specialist",
9110 "123abc",
9111 "",
9112 "has spaces",
9113 "../path",
9114 "a/b",
9115 "UPPER",
9116 ];
9117
9118 for name in invalid_names {
9119 let result = tool
9120 .spawn(SpawnAgentInput {
9121 name: name.into(),
9122 system_prompt: "test".into(),
9123 tools: vec![],
9124 task: "test".into(),
9125 })
9126 .await
9127 .unwrap();
9128 assert!(
9129 result.is_error,
9130 "expected error for name '{name}', got success: {}",
9131 result.content
9132 );
9133 assert!(
9134 result.content.contains("Invalid agent name"),
9135 "expected 'Invalid agent name' in error for name '{name}', got: {}",
9136 result.content
9137 );
9138 }
9139 }
9140
9141 #[tokio::test]
9142 async fn spawn_agent_rejects_duplicate_name() {
9143 let provider = Arc::new(MockProvider::new(vec![
9144 CompletionResponse {
9145 content: vec![ContentBlock::Text {
9146 text: "done".into(),
9147 }],
9148 usage: TokenUsage::default(),
9149 stop_reason: StopReason::EndTurn,
9150 reasoning: None,
9151 model: None,
9152 },
9153 ]));
9155 let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9156
9157 let r1 = tool
9159 .spawn(SpawnAgentInput {
9160 name: "helper".into(),
9161 system_prompt: "test".into(),
9162 tools: vec![],
9163 task: "test".into(),
9164 })
9165 .await
9166 .unwrap();
9167 assert!(!r1.is_error);
9168
9169 let r2 = tool
9171 .spawn(SpawnAgentInput {
9172 name: "helper".into(),
9173 system_prompt: "test".into(),
9174 tools: vec![],
9175 task: "test".into(),
9176 })
9177 .await
9178 .unwrap();
9179 assert!(r2.is_error);
9180 assert!(r2.content.contains("already used"));
9181 }
9182
9183 #[tokio::test]
9184 async fn spawn_agent_enforces_tool_allowlist() {
9185 let provider = Arc::new(MockProvider::new(vec![]));
9186 let mut config = make_spawn_config();
9187 config.tool_allowlist = vec!["mock_read".into()];
9188
9189 let mock = MockTool::new("mock_read", "file content");
9190 let tool = build_spawn_tool(provider, config, vec![Arc::new(mock)]);
9191
9192 let result = tool
9194 .spawn(SpawnAgentInput {
9195 name: "reader".into(),
9196 system_prompt: "test".into(),
9197 tools: vec!["bash".into()],
9198 task: "test".into(),
9199 })
9200 .await
9201 .unwrap();
9202 assert!(result.is_error);
9203 assert!(result.content.contains("not in allowlist"));
9204 }
9205
9206 #[tokio::test]
9207 async fn spawn_agent_count_cap() {
9208 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9209 content: vec![ContentBlock::Text {
9210 text: "done".into(),
9211 }],
9212 usage: TokenUsage::default(),
9213 stop_reason: StopReason::EndTurn,
9214 reasoning: None,
9215 model: None,
9216 }]));
9217 let mut config = make_spawn_config();
9218 config.max_spawned_agents = 1;
9219 let tool = build_spawn_tool(provider, config, vec![]);
9220
9221 let r1 = tool
9223 .spawn(SpawnAgentInput {
9224 name: "first".into(),
9225 system_prompt: "test".into(),
9226 tools: vec![],
9227 task: "test".into(),
9228 })
9229 .await
9230 .unwrap();
9231 assert!(!r1.is_error);
9232
9233 let r2 = tool
9235 .spawn(SpawnAgentInput {
9236 name: "second".into(),
9237 system_prompt: "test".into(),
9238 tools: vec![],
9239 task: "test".into(),
9240 })
9241 .await
9242 .unwrap();
9243 assert!(r2.is_error);
9244 assert!(r2.content.contains("Spawn limit reached"));
9245 }
9246
9247 #[tokio::test]
9248 async fn spawn_agent_count_cap_holds_under_concurrent_calls() {
9249 let mk = || CompletionResponse {
9253 content: vec![ContentBlock::Text {
9254 text: "done".into(),
9255 }],
9256 usage: TokenUsage::default(),
9257 stop_reason: StopReason::EndTurn,
9258 reasoning: None,
9259 model: None,
9260 };
9261 let provider = Arc::new(MockProvider::new(vec![mk(), mk()]));
9263 let mut config = make_spawn_config();
9264 config.max_spawned_agents = 1;
9265 let tool = build_spawn_tool(provider, config, vec![]);
9266
9267 let (r1, r2) = tokio::join!(
9268 tool.spawn(SpawnAgentInput {
9269 name: "first".into(),
9270 system_prompt: "t".into(),
9271 tools: vec![],
9272 task: "t".into(),
9273 }),
9274 tool.spawn(SpawnAgentInput {
9275 name: "second".into(),
9276 system_prompt: "t".into(),
9277 tools: vec![],
9278 task: "t".into(),
9279 }),
9280 );
9281 let r1 = r1.unwrap();
9282 let r2 = r2.unwrap();
9283 let errors = [&r1, &r2].iter().filter(|r| r.is_error).count();
9284 assert_eq!(
9285 errors, 1,
9286 "exactly one of two concurrent spawns must be rejected by the cap"
9287 );
9288 assert!(
9289 r1.content.contains("Spawn limit reached")
9290 || r2.content.contains("Spawn limit reached")
9291 );
9292 }
9293
9294 #[tokio::test]
9295 async fn spawn_agent_token_budget_enforcement() {
9296 let provider = Arc::new(MockProvider::new(vec![]));
9297 let mut config = make_spawn_config();
9298 config.max_total_tokens = 100;
9299 let tool = build_spawn_tool(provider, config, vec![]);
9300
9301 {
9303 let mut acc = tool.accumulated_tokens.lock().unwrap();
9304 *acc = TokenUsage {
9305 input_tokens: 60,
9306 output_tokens: 50,
9307 ..Default::default()
9308 };
9309 }
9310
9311 let result = tool
9312 .spawn(SpawnAgentInput {
9313 name: "spender".into(),
9314 system_prompt: "test".into(),
9315 tools: vec![],
9316 task: "test".into(),
9317 })
9318 .await
9319 .unwrap();
9320 assert!(result.is_error);
9321 assert!(result.content.contains("budget exhausted"));
9322 }
9323
9324 #[tokio::test]
9325 async fn spawn_agent_no_delegation_tools() {
9326 let provider = Arc::new(MockProvider::new(vec![]));
9329 let mut config = make_spawn_config();
9330 config.tool_allowlist = vec!["mock_read".into()];
9331
9332 let mock = MockTool::new("mock_read", "content");
9333 let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(mock)];
9334
9335 let mut tool_pool = std::collections::HashMap::new();
9336 for t in &tools {
9337 let name = t.definition().name;
9338 if config.tool_allowlist.contains(&name) {
9339 tool_pool.insert(name, t.clone());
9340 }
9341 }
9342 tool_pool.insert(
9344 "delegate_task".into(),
9345 Arc::new(MockTool::new("delegate_task", "bad")),
9346 );
9347 tool_pool.remove("delegate_task");
9348 tool_pool.remove("form_squad");
9349 tool_pool.remove("spawn_agent");
9350
9351 assert!(tool_pool.contains_key("mock_read"));
9353 assert!(!tool_pool.contains_key("delegate_task"));
9354 assert!(!tool_pool.contains_key("form_squad"));
9355 assert!(!tool_pool.contains_key("spawn_agent"));
9356 drop(provider);
9357 }
9358
9359 #[tokio::test]
9360 async fn spawn_agent_emits_events() {
9361 let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(vec![]));
9362 let events_clone = events.clone();
9363
9364 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9365 content: vec![ContentBlock::Text {
9366 text: "result".into(),
9367 }],
9368 usage: TokenUsage::default(),
9369 stop_reason: StopReason::EndTurn,
9370 reasoning: None,
9371 model: None,
9372 }]));
9373
9374 let mut tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9375 tool.on_event = Some(Arc::new(move |e: AgentEvent| {
9376 events_clone.lock().unwrap().push(e);
9377 }));
9378
9379 let _ = tool
9380 .spawn(SpawnAgentInput {
9381 name: "emitter".into(),
9382 system_prompt: "test".into(),
9383 tools: vec![],
9384 task: "test".into(),
9385 })
9386 .await;
9387
9388 let events = events.lock().unwrap();
9389 let spawned = events
9390 .iter()
9391 .any(|e| matches!(e, AgentEvent::AgentSpawned { spawned_name, .. } if spawned_name == "spawn:emitter"));
9392 assert!(spawned, "expected AgentSpawned event");
9393
9394 let completed = events
9395 .iter()
9396 .any(|e| matches!(e, AgentEvent::SubAgentCompleted { agent, success, .. } if agent == "spawn:emitter" && *success));
9397 assert!(completed, "expected SubAgentCompleted event");
9398 }
9399
9400 #[tokio::test]
9401 async fn spawn_agent_empty_tools() {
9402 let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9404 content: vec![ContentBlock::Text {
9405 text: "Pure reasoning response".into(),
9406 }],
9407 usage: TokenUsage::default(),
9408 stop_reason: StopReason::EndTurn,
9409 reasoning: None,
9410 model: None,
9411 }]));
9412
9413 let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9414
9415 let result = tool
9416 .spawn(SpawnAgentInput {
9417 name: "thinker".into(),
9418 system_prompt: "You are a reasoning agent.".into(),
9419 tools: vec![],
9420 task: "Think about this.".into(),
9421 })
9422 .await
9423 .unwrap();
9424 assert!(!result.is_error);
9425 assert!(result.content.contains("Pure reasoning response"));
9426 }
9427
9428 #[tokio::test]
9429 async fn spawn_agent_prompt_too_long() {
9430 let provider = Arc::new(MockProvider::new(vec![]));
9431 let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9432
9433 let long_prompt = "x".repeat(SPAWN_MAX_PROMPT_BYTES + 1);
9434 let result = tool
9435 .spawn(SpawnAgentInput {
9436 name: "verbose".into(),
9437 system_prompt: long_prompt,
9438 tools: vec![],
9439 task: "test".into(),
9440 })
9441 .await
9442 .unwrap();
9443 assert!(result.is_error);
9444 assert!(result.content.contains("System prompt too long"));
9445 }
9446
9447 #[test]
9448 fn spawn_config_validation_rejects_zero_agents() {
9449 let toml_str = r#"
9450[provider]
9451name = "anthropic"
9452model = "claude-sonnet-4-20250514"
9453
9454[orchestrator.spawn]
9455max_spawned_agents = 0
9456"#;
9457 let err = crate::config::HeartbitConfig::from_toml(toml_str).unwrap_err();
9458 assert!(
9459 err.to_string()
9460 .contains("max_spawned_agents must be at least 1"),
9461 "err: {err}"
9462 );
9463 }
9464
9465 #[test]
9466 fn spawn_config_validation_rejects_zero_turns() {
9467 let toml_str = r#"
9468[provider]
9469name = "anthropic"
9470model = "claude-sonnet-4-20250514"
9471
9472[orchestrator.spawn]
9473max_turns = 0
9474"#;
9475 let err = crate::config::HeartbitConfig::from_toml(toml_str).unwrap_err();
9476 assert!(
9477 err.to_string().contains("max_turns must be at least 1"),
9478 "err: {err}"
9479 );
9480 }
9481
9482 #[test]
9483 fn spawn_config_from_toml() {
9484 let toml_str = r#"
9485[provider]
9486name = "anthropic"
9487model = "claude-sonnet-4-20250514"
9488
9489[orchestrator.spawn]
9490max_spawned_agents = 5
9491tool_allowlist = ["read", "grep", "bash"]
9492max_turns = 20
9493max_tokens = 8192
9494max_total_tokens = 100000
9495"#;
9496 let config: crate::config::HeartbitConfig = toml::from_str(toml_str).unwrap();
9497 let spawn = config.orchestrator.spawn.as_ref().unwrap();
9498 assert_eq!(spawn.max_spawned_agents, 5);
9499 assert_eq!(spawn.tool_allowlist, vec!["read", "grep", "bash"]);
9500 assert_eq!(spawn.max_turns, 20);
9501 assert_eq!(spawn.max_tokens, 8192);
9502 assert_eq!(spawn.max_total_tokens, 100_000);
9503 }
9504
9505 #[test]
9506 fn spawn_disabled_by_default() {
9507 let toml_str = r#"
9508[provider]
9509name = "anthropic"
9510model = "claude-sonnet-4-20250514"
9511"#;
9512 let config: crate::config::HeartbitConfig = toml::from_str(toml_str).unwrap();
9513 assert!(config.orchestrator.spawn.is_none());
9514 }
9515
9516 #[test]
9517 fn spawn_config_invalid_tool_rejected_at_build() {
9518 let provider = Arc::new(MockProvider::new(vec![]));
9519 let mut config = make_spawn_config();
9520 config.tool_allowlist = vec!["nonexistent_tool".into()];
9521
9522 let result = Orchestrator::builder(provider)
9523 .sub_agent("worker", "does work", "You work.")
9524 .spawn_config(config, vec![]) .build();
9526
9527 match result {
9528 Err(e) => {
9529 let msg = e.to_string();
9530 assert!(
9531 msg.contains("nonexistent_tool"),
9532 "expected error mentioning the bad tool, got: {msg}"
9533 );
9534 }
9535 Ok(_) => panic!("expected build error for invalid tool in allowlist"),
9536 }
9537 }
9538
9539 #[test]
9540 fn spawn_tool_definition_includes_allowlist() {
9541 let mut config = make_spawn_config();
9542 config.tool_allowlist = vec!["read".into(), "grep".into()];
9543 let def = SpawnAgentTool::build_definition(&config);
9544 assert_eq!(def.name, "spawn_agent");
9545 assert!(def.description.contains("read, grep"));
9546 assert!(def.description.contains("3 agents max"));
9547 }
9548
9549 #[tokio::test]
9550 async fn spawn_system_prompt_added_when_configured() {
9551 use std::sync::Arc;
9554
9555 let provider = Arc::new(MockProvider::new(vec![
9556 CompletionResponse {
9558 content: vec![ContentBlock::Text {
9559 text: "No delegation needed.".into(),
9560 }],
9561 usage: TokenUsage::default(),
9562 stop_reason: StopReason::EndTurn,
9563 reasoning: None,
9564 model: None,
9565 },
9566 ]));
9567
9568 let config = make_spawn_config();
9569 let mut orchestrator = Orchestrator::builder(provider)
9570 .sub_agent("worker", "does work", "You work.")
9571 .spawn_config(config, vec![])
9572 .build()
9573 .unwrap();
9574
9575 let output = orchestrator.run("test task").await.unwrap();
9576 assert!(output.result.contains("No delegation needed."));
9578 }
9579
9580 #[tokio::test(flavor = "multi_thread")]
9604 async fn orchestrator_propagates_tenant_tracker_to_sub_agents() {
9605 use crate::agent::tenant_tracker::TenantTokenTracker;
9606 use crate::auth::TenantScope;
9607
9608 let tracker = Arc::new(TenantTokenTracker::new(1_000_000));
9609
9610 let scope = TenantScope::new("tenant-abc");
9613 drop(tracker.reserve(&scope, 100_000).unwrap());
9614
9615 let initial_snap = tracker.snapshot();
9617 assert_eq!(initial_snap.len(), 1, "entry must exist after reserve+drop");
9618 assert_eq!(initial_snap[0].1.in_flight, 0);
9619
9620 let provider = Arc::new(MockProvider::new(vec![
9624 CompletionResponse {
9626 content: vec![ContentBlock::ToolUse {
9627 id: "tt-call-1".into(),
9628 name: "delegate_task".into(),
9629 input: json!({
9630 "tasks": [{"agent": "worker", "task": "do work"}]
9631 }),
9632 }],
9633 stop_reason: StopReason::ToolUse,
9634 reasoning: None,
9635 usage: TokenUsage {
9636 input_tokens: 5,
9637 output_tokens: 5,
9638 ..Default::default()
9639 },
9640 model: None,
9641 },
9642 CompletionResponse {
9644 content: vec![ContentBlock::Text {
9645 text: "Work done.".into(),
9646 }],
9647 stop_reason: StopReason::EndTurn,
9648 reasoning: None,
9649 usage: TokenUsage {
9650 input_tokens: 400,
9651 output_tokens: 100,
9652 ..Default::default()
9653 },
9654 model: None,
9655 },
9656 CompletionResponse {
9658 content: vec![ContentBlock::Text {
9659 text: "All done.".into(),
9660 }],
9661 stop_reason: StopReason::EndTurn,
9662 reasoning: None,
9663 usage: TokenUsage {
9664 input_tokens: 2,
9665 output_tokens: 2,
9666 ..Default::default()
9667 },
9668 model: None,
9669 },
9670 ]));
9671
9672 let mut orch = Orchestrator::builder(provider)
9675 .audit_user_context("user-1", "tenant-abc")
9676 .sub_agent("worker", "Does work", "You work.")
9677 .tenant_tracker(tracker.clone())
9678 .build()
9679 .unwrap();
9680
9681 orch.run("do work").await.unwrap();
9682
9683 let snap_mid = tracker.snapshot();
9686 let state_mid = snap_mid
9687 .iter()
9688 .find(|(tid, _)| tid == "tenant-abc")
9689 .map(|(_, s)| s.clone())
9690 .expect("entry for 'tenant-abc' should still exist after the run");
9691
9692 assert!(
9693 state_mid.high_water >= 500,
9694 "high_water ({}) must be >= 500 (sub-agent's 500 tokens); \
9695 if it is < 500, the sub-agent runner is not propagating the tracker",
9696 state_mid.high_water
9697 );
9698
9699 drop(orch);
9702
9703 let snap_final = tracker.snapshot();
9704 let state_final = snap_final
9705 .iter()
9706 .find(|(tid, _)| tid == "tenant-abc")
9707 .map(|(_, s)| s.clone())
9708 .expect("entry should still exist after drop");
9709
9710 assert_eq!(
9711 state_final.in_flight, 0,
9712 "in_flight should return to 0 after all runners are dropped"
9713 );
9714 }
9715}